spb/zyquo-cloud Public MIT
Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.
Swift 97.4%
Shell 1.7%
Makefile 1%
1//2// SyntaxHighlighter.swift3// Zyquo Cloud4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Lightweight scanner-based syntax highlighter for code blocks. Supports the9// Phase 4 language set (Swift, Python, JS/TS, JSON, HTML, CSS, Bash, SQL, Go,10// Rust, C/C++/Obj-C). Token colors are code-specific design tokens defined in11// `CodeTheme` with the same dynamic light/dark pattern as `ZyquoColor`,12// referencing existing semantic tokens where they fit. Unknown languages fall13// back to plain text in the base color.14//1516import SwiftUI1718// MARK: - Code theme1920/// Semantic colors for code tokens. Dynamic (light/dark) and coherent with the21/// app palette: indigo/sky family for keywords, success-green strings, tertiary22/// gray comments, amber numbers.23struct CodeTheme {24 let keyword: Color25 let string: Color26 let comment: Color27 let number: Color28 let type: Color29 let functionCall: Color30 let property: Color31 let attribute: Color3233 /// The default Zyquo Cloud code theme.34 static let zyquo = CodeTheme(35 keyword: ZyquoColor.accent,36 string: ZyquoColor.success,37 comment: ZyquoColor.textTertiary,38 number: dynamic(light: 0xB26A0B, dark: 0xE0A458),39 type: dynamic(light: 0x2380C2, dark: 0x62B7F0),40 functionCall: dynamic(light: 0x6E4FD4, dark: 0xA48CF2),41 property: dynamic(light: 0x2F6FBF, dark: 0x7FB4E8),42 attribute: ZyquoColor.warning43 )4445 /// Same dynamic-color pattern as `ZyquoColor` (resolved per appearance).46 private static func dynamic(light: UInt32, dark: UInt32) -> Color {47 Color(nsColor: NSColor(name: nil) { appearance in48 let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light49 return NSColor(hex: hex)50 })51 }52}5354// MARK: - Highlighter5556enum SyntaxHighlighter {57 /// The active code theme.58 static let theme = CodeTheme.zyquo5960 /// Highlights `code` for `language`, returning an `AttributedString` whose61 /// text is byte-for-byte identical to the input. Plain (unclassified) text62 /// is colored with `baseColor`. Unknown or nil languages return the whole63 /// string in `baseColor`. Results are memoized (streaming re-renders hit64 /// the cache for every already-completed block).65 static func highlight(_ code: String, language: String?, baseColor: Color) -> AttributedString {66 guard !code.isEmpty else { return AttributedString() }67 guard let profile = profile(for: language) else {68 var plain = AttributedString(code)69 plain.foregroundColor = baseColor70 return plain71 }7273 let key = CacheKey(74 textHash: code.hashValue,75 length: code.count,76 language: language?.lowercased() ?? "",77 base: String(describing: baseColor)78 )79 cacheLock.lock()80 if let hit = cache[key] {81 cacheLock.unlock()82 return hit83 }84 cacheLock.unlock()8586 let result = tokenize(code, profile: profile, baseColor: baseColor)8788 cacheLock.lock()89 if cache.count > cacheCapacity { cache.removeAll(keepingCapacity: true) }90 cache[key] = result91 cacheLock.unlock()92 return result93 }9495 // MARK: Cache9697 private struct CacheKey: Hashable {98 let textHash: Int99 let length: Int100 let language: String101 let base: String102 }103104 private static let cacheLock = NSLock()105 private static let cacheCapacity = 128106 private static var cache: [CacheKey: AttributedString] = [:]107108 // MARK: Scanner109110 private static func tokenize(_ code: String, profile: LanguageProfile, baseColor: Color) -> AttributedString {111 let chars = Array(code)112 var result = AttributedString()113 var i = 0114 var pendingPlainStart = 0115 var previousSignificant: Character?116117 func matches(_ marker: [Character], at index: Int) -> Bool {118 guard index + marker.count <= chars.count else { return false }119 for (offset, ch) in marker.enumerated() where chars[index + offset] != ch {120 return false121 }122 return true123 }124125 func nextSignificant(after index: Int) -> Character? {126 var j = index127 while j < chars.count, chars[j] == " " || chars[j] == "\t" { j += 1 }128 return j < chars.count ? chars[j] : nil129 }130131 func flushPlain(upTo end: Int) {132 guard end > pendingPlainStart else { return }133 var segment = AttributedString(String(chars[pendingPlainStart ..< end]))134 segment.foregroundColor = baseColor135 result += segment136 pendingPlainStart = end137 }138139 func emit(_ start: Int, _ end: Int, _ color: Color) {140 flushPlain(upTo: start)141 var segment = AttributedString(String(chars[start ..< end]))142 segment.foregroundColor = color143 result += segment144 pendingPlainStart = end145 }146147 func isIdentifierStart(_ c: Character) -> Bool {148 c.isLetter || c == "_" || profile.identifierExtras.contains(c)149 }150151 func isIdentifierBody(_ c: Character) -> Bool {152 c.isLetter || c.isNumber || c == "_" || profile.identifierExtras.contains(c)153 }154155 while i < chars.count {156 let c = chars[i]157158 // Block comments (unterminated ones run to EOF — streaming safe).159 if let block = profile.blockComments.first(where: { matches($0.open, at: i) }) {160 let start = i161 i += block.open.count162 while i < chars.count, !matches(block.close, at: i) { i += 1 }163 if i < chars.count { i += block.close.count }164 emit(start, i, theme.comment)165 previousSignificant = nil166 continue167 }168169 // Line comments.170 if let line = profile.lineComments.first(where: { matches($0, at: i) }) {171 let start = i172 i += line.count173 while i < chars.count, chars[i] != "\n" { i += 1 }174 emit(start, i, theme.comment)175 previousSignificant = nil176 continue177 }178179 // Strings (with backslash escapes; triple quotes for Python-style).180 if profile.stringDelimiters.contains(c) {181 let start = i182 let triple = [c, c, c]183 if matches(triple, at: i) {184 i += 3185 while i < chars.count, !matches(triple, at: i) { i += 1 }186 if i < chars.count { i += 3 }187 } else {188 i += 1189 while i < chars.count {190 if chars[i] == "\\" { i += 2; continue }191 if chars[i] == c { i += 1; break }192 i += 1193 }194 i = min(i, chars.count)195 }196 let isKey = profile.stringKeyAsProperty && nextSignificant(after: i) == ":"197 emit(start, i, isKey ? theme.property : theme.string)198 previousSignificant = c199 continue200 }201202 // Numbers (plus #hex colors for CSS).203 if c.isNumber || (profile.hashIsNumberPrefix && c == "#" && i + 1 < chars.count && chars[i + 1].isHexDigit) {204 let start = i205 i += 1206 while i < chars.count,207 chars[i].isLetter || chars[i].isNumber || chars[i] == "." || chars[i] == "_" {208 i += 1209 }210 emit(start, i, theme.number)211 previousSignificant = chars[i - 1]212 continue213 }214215 // Attributes / decorators / directives (@escaping, #include, $VAR…).216 if profile.attributePrefixes.contains(c), i + 1 < chars.count, isIdentifierStart(chars[i + 1]) {217 let start = i218 i += 1219 while i < chars.count, isIdentifierBody(chars[i]) { i += 1 }220 emit(start, i, theme.attribute)221 previousSignificant = chars[i - 1]222 continue223 }224225 // Identifiers: keywords, types, calls, properties.226 if isIdentifierStart(c) {227 let start = i228 while i < chars.count, isIdentifierBody(chars[i]) { i += 1 }229 let word = String(chars[start ..< i])230 let lookup = profile.caseInsensitiveKeywords ? word.lowercased() : word231 let next = nextSignificant(after: i)232 var color: Color?233234 if profile.keywords.contains(lookup) {235 color = theme.keyword236 } else if profile.isMarkup {237 if let prev = previousSignificant, prev == "<" || prev == "/" || prev == "!" {238 color = theme.keyword // tag name239 } else if next == "=" {240 color = theme.property // tag attribute241 }242 } else if previousSignificant == "." {243 color = theme.property244 } else if next == "(" {245 color = theme.functionCall246 } else if let first = word.first, first.isUppercase {247 color = theme.type248 } else if profile.colonMeansProperty, next == ":" {249 color = theme.property250 }251252 if let color { emit(start, i, color) }253 previousSignificant = chars[i - 1]254 continue255 }256257 if !c.isWhitespace { previousSignificant = c }258 i += 1259 }260261 flushPlain(upTo: chars.count)262 return result263 }264265 // MARK: Language profiles266267 private struct LanguageProfile {268 var keywords: Set<String> = []269 var lineComments: [[Character]] = []270 var blockComments: [(open: [Character], close: [Character])] = []271 var stringDelimiters: Set<Character> = ["\""]272 var identifierExtras: Set<Character> = []273 var attributePrefixes: Set<Character> = []274 var caseInsensitiveKeywords = false275 var colonMeansProperty = false276 var hashIsNumberPrefix = false277 var stringKeyAsProperty = false278 var isMarkup = false279 }280281 private static func profile(for language: String?) -> LanguageProfile? {282 guard let language else { return nil }283 let normalized = language.trimmingCharacters(in: .whitespaces).lowercased()284 return profiles[normalized]285 }286287 private static let profiles: [String: LanguageProfile] = {288 let slashLine: [[Character]] = [Array("//")]289 let cBlock: [(open: [Character], close: [Character])] = [(Array("/*"), Array("*/"))]290291 var table: [String: LanguageProfile] = [:]292293 let swift = LanguageProfile(294 keywords: [295 "func", "let", "var", "if", "else", "guard", "switch", "case", "default",296 "for", "while", "repeat", "in", "return", "import", "struct", "class",297 "enum", "protocol", "extension", "where", "as", "is", "try", "catch",298 "throw", "throws", "rethrows", "async", "await", "actor", "init", "deinit",299 "self", "Self", "super", "nil", "true", "false", "public", "private",300 "internal", "fileprivate", "open", "static", "final", "lazy", "weak",301 "unowned", "mutating", "nonmutating", "override", "defer", "typealias",302 "associatedtype", "some", "any", "break", "continue", "fallthrough", "do",303 "get", "set", "willSet", "didSet", "inout", "subscript", "operator",304 "indirect", "convenience", "required", "optional", "dynamic",305 ],306 lineComments: slashLine,307 blockComments: cBlock,308 attributePrefixes: ["@", "#"]309 )310 table["swift"] = swift311312 let python = LanguageProfile(313 keywords: [314 "def", "class", "if", "elif", "else", "for", "while", "in", "return",315 "import", "from", "as", "with", "try", "except", "finally", "raise",316 "lambda", "pass", "break", "continue", "global", "nonlocal", "yield",317 "assert", "del", "not", "and", "or", "is", "None", "True", "False",318 "async", "await", "match", "case", "self",319 ],320 lineComments: [Array("#")],321 stringDelimiters: ["\"", "'"],322 attributePrefixes: ["@"]323 )324 for alias in ["python", "py", "python3"] { table[alias] = python }325326 let jsTs = LanguageProfile(327 keywords: [328 "function", "const", "let", "var", "if", "else", "for", "while", "do",329 "switch", "case", "default", "return", "break", "continue", "new",330 "delete", "typeof", "instanceof", "in", "of", "class", "extends",331 "super", "this", "import", "export", "from", "as", "async", "await",332 "yield", "try", "catch", "finally", "throw", "void", "null", "undefined",333 "true", "false", "static", "get", "set", "interface", "type", "enum",334 "implements", "declare", "readonly", "namespace", "public", "private",335 "protected", "abstract", "satisfies", "keyof", "infer", "never",336 "unknown", "any", "string", "number", "boolean", "object", "symbol",337 "bigint",338 ],339 lineComments: slashLine,340 blockComments: cBlock,341 stringDelimiters: ["\"", "'", "`"],342 identifierExtras: ["$"],343 attributePrefixes: ["@"]344 )345 for alias in ["javascript", "js", "jsx", "typescript", "ts", "tsx"] { table[alias] = jsTs }346347 let json = LanguageProfile(348 keywords: ["true", "false", "null"],349 lineComments: slashLine,350 blockComments: cBlock,351 stringKeyAsProperty: true352 )353 table["json"] = json354 table["jsonc"] = json355356 let html = LanguageProfile(357 blockComments: [(Array("<!--"), Array("-->"))],358 stringDelimiters: ["\"", "'"],359 identifierExtras: ["-"],360 isMarkup: true361 )362 for alias in ["html", "xml", "svg", "xhtml"] { table[alias] = html }363364 let css = LanguageProfile(365 keywords: ["important", "inherit", "initial", "unset", "auto", "none", "revert"],366 blockComments: cBlock,367 stringDelimiters: ["\"", "'"],368 identifierExtras: ["-"],369 attributePrefixes: ["@"],370 colonMeansProperty: true,371 hashIsNumberPrefix: true372 )373 for alias in ["css", "scss", "less"] { table[alias] = css }374375 let bash = LanguageProfile(376 keywords: [377 "if", "then", "else", "elif", "fi", "for", "while", "until", "do",378 "done", "case", "esac", "function", "in", "select", "time", "coproc",379 "echo", "cd", "export", "local", "return", "exit", "read", "set",380 "unset", "shift", "source", "alias", "eval", "exec", "printf", "test",381 "true", "false", "sudo", "trap", "declare",382 ],383 lineComments: [Array("#")],384 stringDelimiters: ["\"", "'"],385 identifierExtras: ["-"],386 attributePrefixes: ["$"]387 )388 for alias in ["bash", "sh", "zsh", "shell", "console"] { table[alias] = bash }389390 let sql = LanguageProfile(391 keywords: [392 "select", "from", "where", "insert", "into", "values", "update",393 "delete", "set", "create", "table", "drop", "alter", "index", "view",394 "join", "inner", "left", "right", "outer", "full", "cross", "on", "as",395 "and", "or", "not", "null", "primary", "key", "foreign", "references",396 "group", "by", "order", "having", "limit", "offset", "distinct",397 "union", "all", "exists", "between", "like", "in", "is", "case",398 "when", "then", "else", "end", "count", "sum", "avg", "min", "max",399 "desc", "asc", "with", "constraint", "unique", "default", "begin",400 "commit", "rollback", "transaction",401 ],402 lineComments: [Array("--")],403 blockComments: cBlock,404 stringDelimiters: ["'", "\""],405 caseInsensitiveKeywords: true406 )407 table["sql"] = sql408409 let go = LanguageProfile(410 keywords: [411 "func", "package", "import", "var", "const", "type", "struct",412 "interface", "map", "chan", "go", "defer", "if", "else", "for",413 "range", "switch", "case", "default", "return", "break", "continue",414 "fallthrough", "select", "goto", "true", "false", "nil", "iota",415 "make", "new", "len", "cap", "append", "copy", "delete", "panic",416 "recover", "error", "string", "int", "int8", "int16", "int32", "int64",417 "uint", "uint8", "uint16", "uint32", "uint64", "bool", "byte", "rune",418 "float32", "float64", "complex64", "complex128", "any",419 ],420 lineComments: slashLine,421 blockComments: cBlock,422 stringDelimiters: ["\"", "'", "`"]423 )424 table["go"] = go425 table["golang"] = go426427 let rust = LanguageProfile(428 keywords: [429 "fn", "let", "mut", "const", "static", "if", "else", "match", "for",430 "while", "loop", "in", "return", "break", "continue", "struct", "enum",431 "trait", "impl", "pub", "use", "mod", "crate", "self", "Self", "super",432 "where", "as", "ref", "move", "async", "await", "dyn", "unsafe",433 "extern", "type", "true", "false", "Some", "None", "Ok", "Err",434 "String", "str", "i8", "i16", "i32", "i64", "i128", "u8", "u16", "u32",435 "u64", "u128", "f32", "f64", "usize", "isize", "bool", "char", "Box",436 "Vec", "Option", "Result",437 ],438 lineComments: slashLine,439 blockComments: cBlock,440 attributePrefixes: ["#"]441 )442 table["rust"] = rust443 table["rs"] = rust444445 let cFamily = LanguageProfile(446 keywords: [447 "int", "char", "float", "double", "void", "long", "short", "signed",448 "unsigned", "if", "else", "for", "while", "do", "switch", "case",449 "default", "return", "break", "continue", "struct", "union", "enum",450 "typedef", "const", "static", "extern", "inline", "sizeof", "goto",451 "volatile", "register", "auto", "bool", "true", "false", "class",452 "public", "private", "protected", "virtual", "override", "final",453 "template", "typename", "namespace", "using", "new", "delete", "this",454 "nullptr", "try", "catch", "throw", "constexpr", "noexcept", "friend",455 "operator", "explicit", "mutable", "id", "instancetype", "nonatomic",456 "strong", "weak", "copy", "readonly", "readwrite", "assign", "nil",457 "YES", "NO",458 ],459 lineComments: slashLine,460 blockComments: cBlock,461 stringDelimiters: ["\"", "'"],462 attributePrefixes: ["@", "#"]463 )464 for alias in ["c", "cpp", "c++", "cc", "cxx", "h", "hpp", "objc", "objective-c", "objectivec", "m", "mm"] {465 table[alias] = cFamily466 }467468 return table469 }()470}471