SPB Git

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%
19.3 KB · 471 lines swift
Raw Blame History
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