import Foundation import SwiftUI #if os(macOS) import AppKit #else import UIKit #endif enum ChatCodeTokenKind: Equatable { case plain case keyword case string case comment case number } struct ChatCodeToken: Equatable { let kind: ChatCodeTokenKind let text: String } /// Minimal single-pass tokenizer for chat code blocks: keywords, strings, /// comments, and numbers. Unknown languages pass through unstyled; correctness /// of the source text is never altered, only colors are added. enum ChatCodeHighlighter { struct Language { struct BlockComment { let open: String let close: String } let keywords: Set let lineCommentPrefixes: [String] let lineCommentNeedsBoundary: Bool let blockComment: BlockComment? let nestedBlockComments: Bool let stringDelimiters: Set let supportsTripleQuotes: Bool init( keywords: Set, lineCommentPrefixes: [String], lineCommentNeedsBoundary: Bool = false, blockComment: BlockComment?, nestedBlockComments: Bool = false, stringDelimiters: Set, supportsTripleQuotes: Bool) { self.keywords = keywords self.lineCommentPrefixes = lineCommentPrefixes self.lineCommentNeedsBoundary = lineCommentNeedsBoundary self.blockComment = blockComment self.nestedBlockComments = nestedBlockComments self.stringDelimiters = stringDelimiters self.supportsTripleQuotes = supportsTripleQuotes } } static let maxHighlightedLines = 200 static let maxHighlightedBytes = 20000 static func language(for id: String?) -> Language? { switch id { case "swift": self.swift case "kotlin", "kt", "kts": self.kotlin case "typescript", "ts", "tsx", "javascript", "js", "jsx", "mjs", "cjs": self.typescript case "python", "py", "python3": self.python case "bash", "sh", "shell", "zsh", "console": self.bash case "json", "jsonc", "json5": self.json default: nil } } static func attributedCode(_ code: String, languageId: String?) -> AttributedString { guard let language = self.language(for: languageId), self.isWithinHighlightLimits(code) else { return AttributedString(code) } var output = AttributedString() for token in self.tokens(code: code, language: language) { var piece = AttributedString(token.text) if let color = self.color(for: token.kind) { piece.foregroundColor = color } output += piece } return output } static func tokens(code: String, language: Language) -> [ChatCodeToken] { let chars = Array(code) var tokens: [ChatCodeToken] = [] var plain = "" var index = 0 func flushPlain() { guard !plain.isEmpty else { return } tokens.append(ChatCodeToken(kind: .plain, text: plain)) plain = "" } while index < chars.count { let character = chars[index] if let block = language.blockComment, self.matches(chars, at: index, block.open) { flushPlain() index = self.consumeBlockComment( chars, from: index, block: block, nested: language.nestedBlockComments, into: &tokens) continue } let startsLineComment = language.lineCommentPrefixes.contains { prefix in self.matches(chars, at: index, prefix) && (!language.lineCommentNeedsBoundary || self.isLineCommentBoundary(chars, at: index)) } if startsLineComment { flushPlain() var end = index while end < chars.count, chars[end] != "\n" { end += 1 } tokens.append(ChatCodeToken(kind: .comment, text: String(chars[index.. Int { var depth = 1 var end = start + block.open.count while end < chars.count { if nested, self.matches(chars, at: end, block.open) { depth += 1 end += block.open.count } else if self.matches(chars, at: end, block.close) { depth -= 1 end += block.close.count if depth == 0 { break } } else { end += 1 } } tokens.append(ChatCodeToken(kind: .comment, text: String(chars[start.. Bool { guard index > 0 else { return true } let previous = chars[index - 1] return previous.isWhitespace || ";&|()<>".contains(previous) } static func isWithinHighlightLimits(_ code: String) -> Bool { guard code.utf8.count <= self.maxHighlightedBytes else { return false } let newlineCount = code.lazy.count(where: { $0 == "\n" }) let lineCount = code.isEmpty ? 0 : newlineCount + 1 return lineCount <= self.maxHighlightedLines } private static func consumeString( _ chars: [Character], from start: Int, language: Language, into tokens: inout [ChatCodeToken]) -> Int { let quote = chars[start] let triple = language.supportsTripleQuotes && self.matches(chars, at: start, String(repeating: String(quote), count: 3)) let delimiter = triple ? String(repeating: String(quote), count: 3) : String(quote) // Backtick templates (TS/JS) and triple quotes span lines; plain // quotes stop at the newline so a stray quote can't swallow the block. let multiline = triple || quote == "`" var end = start + delimiter.count while end < chars.count { if chars[end] == "\\" { end += 2 continue } if self.matches(chars, at: end, delimiter) { end += delimiter.count break } if !multiline, chars[end] == "\n" { break } end += 1 } end = min(end, chars.count) tokens.append(ChatCodeToken(kind: .string, text: String(chars[start.. Bool { let needleChars = Array(needle) guard index + needleChars.count <= chars.count else { return false } for offset in 0.. Color? { switch kind { case .plain: nil case .keyword: ChatCodeSyntaxPalette.keyword case .string: ChatCodeSyntaxPalette.string case .comment: ChatCodeSyntaxPalette.comment case .number: ChatCodeSyntaxPalette.number } } // MARK: - Language profiles private static let swift = Language( keywords: [ "actor", "any", "as", "associatedtype", "async", "await", "break", "case", "catch", "class", "continue", "convenience", "default", "defer", "deinit", "do", "else", "enum", "extension", "fallthrough", "false", "fileprivate", "final", "for", "func", "guard", "if", "import", "in", "indirect", "init", "inout", "internal", "is", "lazy", "let", "mutating", "nil", "nonisolated", "open", "operator", "optional", "override", "private", "protocol", "public", "repeat", "required", "rethrows", "return", "self", "some", "static", "struct", "subscript", "super", "switch", "throw", "throws", "true", "try", "typealias", "unowned", "var", "weak", "where", "while", ], lineCommentPrefixes: ["//"], blockComment: Language.BlockComment(open: "/*", close: "*/"), nestedBlockComments: true, stringDelimiters: ["\""], supportsTripleQuotes: true) private static let kotlin = Language( keywords: [ "abstract", "as", "break", "by", "catch", "class", "companion", "constructor", "continue", "crossinline", "data", "do", "else", "enum", "false", "final", "finally", "for", "fun", "if", "import", "in", "init", "inline", "interface", "internal", "is", "lateinit", "null", "object", "open", "out", "override", "package", "private", "protected", "public", "reified", "return", "sealed", "super", "suspend", "this", "throw", "true", "try", "typealias", "val", "var", "vararg", "when", "where", "while", ], lineCommentPrefixes: ["//"], blockComment: Language.BlockComment(open: "/*", close: "*/"), nestedBlockComments: true, stringDelimiters: ["\""], supportsTripleQuotes: true) private static let typescript = Language( keywords: [ "abstract", "any", "as", "async", "await", "boolean", "break", "case", "catch", "class", "const", "continue", "declare", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "from", "function", "get", "if", "implements", "import", "in", "infer", "instanceof", "interface", "is", "keyof", "let", "namespace", "never", "new", "null", "number", "of", "private", "protected", "public", "readonly", "return", "set", "static", "string", "super", "switch", "symbol", "this", "throw", "true", "try", "type", "typeof", "undefined", "unknown", "var", "void", "while", "yield", ], lineCommentPrefixes: ["//"], blockComment: Language.BlockComment(open: "/*", close: "*/"), stringDelimiters: ["\"", "'", "`"], supportsTripleQuotes: false) private static let python = Language( keywords: [ "False", "None", "True", "and", "as", "assert", "async", "await", "break", "case", "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "match", "nonlocal", "not", "or", "pass", "raise", "return", "self", "try", "while", "with", "yield", ], lineCommentPrefixes: ["#"], blockComment: nil, stringDelimiters: ["\"", "'"], supportsTripleQuotes: true) private static let bash = Language( keywords: [ "alias", "case", "cd", "declare", "do", "done", "echo", "elif", "else", "esac", "exit", "export", "false", "fi", "for", "function", "if", "in", "local", "readonly", "return", "select", "set", "shift", "source", "then", "true", "unset", "until", "while", ], lineCommentPrefixes: ["#"], lineCommentNeedsBoundary: true, blockComment: nil, stringDelimiters: ["\"", "'"], supportsTripleQuotes: false) private static let json = Language( keywords: ["false", "null", "true"], lineCommentPrefixes: [], blockComment: nil, stringDelimiters: ["\""], supportsTripleQuotes: false) } /// Content-keyed memo so streaming re-renders reuse finished highlight work /// instead of re-tokenizing every completed block on each delta tick. /// Public so app file previews reuse the chat renderer's highlighting. @MainActor public enum ChatCodeHighlightCache { private static var cache: [String: AttributedString] = [:] // Bounded: wholesale reset past the cap keeps long sessions flat; a miss // just re-tokenizes one visible block, which is cheap. private static let capacity = 160 public static func highlighted(code: String, languageId: String?) -> AttributedString { guard ChatCodeHighlighter.language(for: languageId) != nil, ChatCodeHighlighter.isWithinHighlightLimits(code) else { return AttributedString(code) } let key = "\(languageId ?? "")\u{0}\(code)" if let hit = self.cache[key] { return hit } let value = ChatCodeHighlighter.attributedCode(code, languageId: languageId) if self.cache.count >= self.capacity { self.cache.removeAll(keepingCapacity: true) } self.cache[key] = value return value } } enum ChatCodeSyntaxPalette { static var keyword: Color { self.adaptive(light: (0.68, 0.24, 0.64), dark: (1.00, 0.48, 0.70)) } static var string: Color { self.adaptive(light: (0.82, 0.18, 0.11), dark: (1.00, 0.51, 0.44)) } static var number: Color { self.adaptive(light: (0.15, 0.16, 0.85), dark: (0.85, 0.79, 0.49)) } static var comment: Color { self.adaptive(light: (0.44, 0.50, 0.55), dark: (0.50, 0.55, 0.60)) } private static func adaptive( light: (Double, Double, Double), dark: (Double, Double, Double)) -> Color { #if os(macOS) // Semantic NSColor resolution is unreliable for arbitrary appearances // in SwiftPM (see OpenClawChatTheme); use an explicit dynamic provider. Color(nsColor: NSColor(name: nil, dynamicProvider: { appearance in let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua let value = isDark ? dark : light return NSColor(calibratedRed: value.0, green: value.1, blue: value.2, alpha: 1) })) #else Color(uiColor: UIColor { traits in let value = traits.userInterfaceStyle == .dark ? dark : light return UIColor(red: value.0, green: value.1, blue: value.2, alpha: 1) }) #endif } }