diff --git a/Pique/ContentView.swift b/Pique/ContentView.swift index 191ad5c..0ee41ec 100644 --- a/Pique/ContentView.swift +++ b/Pique/ContentView.swift @@ -17,6 +17,7 @@ struct ContentView: View { ("Shell", "terminal", Color.mint), ("Python", "chevron.left.forwardslash.chevron.right", Color.cyan), ("HCL", "doc.text", Color.indigo), + ("Log", "doc.text.below.ecg", Color.gray), ] var body: some View { diff --git a/Pique/Info.plist b/Pique/Info.plist index dcef16d..2ed1bb4 100644 --- a/Pique/Info.plist +++ b/Pique/Info.plist @@ -96,6 +96,25 @@ + + UTTypeConformsTo + + public.plain-text + + UTTypeDescription + Log File + UTTypeIdentifier + io.macadmins.pique.log + UTTypeTagSpecification + + public.filename-extension + + log + out + err + + + \ No newline at end of file diff --git a/Pique/SettingsView.swift b/Pique/SettingsView.swift index c35f1e3..4b5c793 100644 --- a/Pique/SettingsView.swift +++ b/Pique/SettingsView.swift @@ -21,6 +21,7 @@ struct SettingsView: View { ("JavaScript", "chevron.left.forwardslash.chevron.right", .yellow), ("Markdown", "doc.richtext", .gray), ("HCL", "doc.text", .indigo), + ("Log", "doc.text.below.ecg", .gray), ] @State private var overrides: [String: AppearanceOverride] = [:] diff --git a/PiquePreview/Info.plist b/PiquePreview/Info.plist index 41fb62c..4c1bfc1 100644 --- a/PiquePreview/Info.plist +++ b/PiquePreview/Info.plist @@ -114,6 +114,25 @@ + + UTTypeIdentifier + io.macadmins.pique.log + UTTypeDescription + Log File + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + + log + out + err + + + NSExtension @@ -146,6 +165,9 @@ io.macadmins.pique.hcl io.macadmins.pique.recipe com.apple.terminal.shell-script + io.macadmins.pique.log + com.apple.log + public.log QLFileExtensions @@ -184,6 +206,9 @@ tfvars hcl recipe + log + out + err QLPreviewWidth 1024 diff --git a/PiquePreview/PreviewProvider.swift b/PiquePreview/PreviewProvider.swift index bd6744f..78000aa 100644 --- a/PiquePreview/PreviewProvider.swift +++ b/PiquePreview/PreviewProvider.swift @@ -106,6 +106,7 @@ class PreviewProvider: NSViewController, QLPreviewingController { case "js", "jsx", "ts", "tsx", "mjs", "cjs": return "JavaScript" case "md", "markdown", "adoc": return "Markdown" case "tf", "tfvars", "hcl": return "HCL" + case "log", "out", "err": return "Log" default: return ext } } diff --git a/PiqueTests/FileFormatTests.swift b/PiqueTests/FileFormatTests.swift index 144746f..effb1e1 100644 --- a/PiqueTests/FileFormatTests.swift +++ b/PiqueTests/FileFormatTests.swift @@ -81,6 +81,12 @@ final class FileFormatTests: XCTestCase { } } + func testLog() { + for ext in ["log", "out", "err"] { + XCTAssertEqual(FileFormat(pathExtension: ext), .log, "Expected .log for .\(ext)") + } + } + // MARK: - Case insensitivity func testCaseInsensitive() { @@ -88,6 +94,7 @@ final class FileFormatTests: XCTestCase { XCTAssertEqual(FileFormat(pathExtension: "YAML"), .yaml) XCTAssertEqual(FileFormat(pathExtension: "Toml"), .toml) XCTAssertEqual(FileFormat(pathExtension: "SH"), .shell) + XCTAssertEqual(FileFormat(pathExtension: "LOG"), .log) } // MARK: - Unknown / empty diff --git a/PiqueTests/HighlightIntegrationTests.swift b/PiqueTests/HighlightIntegrationTests.swift index b29d6fa..2ba0c0f 100644 --- a/PiqueTests/HighlightIntegrationTests.swift +++ b/PiqueTests/HighlightIntegrationTests.swift @@ -340,4 +340,97 @@ final class HighlightIntegrationTests: XCTestCase { let html = body(SyntaxHighlighter.highlight(hcl, format: .hcl)) XCTAssertTrue(html.contains(#"class="string""#)) } + + // MARK: - Log files: heuristic highlighting + + func testLogSeverityLevels() { + let log = "ERROR something failed\nWARN low disk\nINFO started\nDEBUG tick" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"ERROR"#)) + XCTAssertTrue(html.contains(#"WARN"#)) + XCTAssertTrue(html.contains(#"INFO"#)) + XCTAssertTrue(html.contains(#"DEBUG"#)) + } + + func testLogISOTimestamp() { + let log = "2026-03-28T14:05:33Z INFO ready" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"2026-03-28T14:05:33Z"#)) + } + + func testLogSyslogTimestamp() { + let log = "Mar 28 14:05:33 myhost syslogd: restart" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"Mar 28 14:05:33"#)) + } + + func testLogIPv4Address() { + let log = "connection from 192.168.1.100 accepted" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"192.168.1.100"#)) + } + + func testLogHTTPMethodAndStatusCode() { + let log = #"GET /api/health 200"# + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"GET"#)) + XCTAssertTrue(html.contains(#"/api/health"#)) + } + + func testLogQuotedString() { + let log = #"message: "disk space low""# + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#""disk space low""#)) + } + + func testLogCriticalSeverity() { + let log = "FATAL out of memory\nCRITICAL disk failure" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"FATAL"#)) + XCTAssertTrue(html.contains(#"CRITICAL"#)) + } + + func testLogApostropheNotTreatedAsString() { + let log = "INFO: Successfully validated the received JWT's signature..." + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertFalse(html.contains(#"'s signature...'"#), + "Apostrophe in JWT's should not start a quoted string") + XCTAssertTrue(html.contains("JWT")) + XCTAssertTrue(html.contains("signature")) + } + + func testLogSeverityWithColon() { + let log = "2025-09-16 06:20:56 +0100 – INFO: Notifying that Docker has been installed" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"INFO:"#)) + } + + func testLogMultiLinePreservesAllLines() { + let log = "INFO line one\nWARN line two\nERROR line three" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"INFO"#)) + XCTAssertTrue(html.contains(#"WARN"#)) + XCTAssertTrue(html.contains(#"ERROR"#)) + XCTAssertTrue(html.contains("line one")) + XCTAssertTrue(html.contains("line two")) + XCTAssertTrue(html.contains("line three")) + } + + func testLogHTTPStatusCodeColoring() { + let log = "status 200\nstatus 404\nstatus 500" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + // 2xx → .ln (green) + XCTAssertTrue(html.contains(#"200"#)) + // 4xx → .lw (orange) + XCTAssertTrue(html.contains(#"404"#)) + // 5xx → .le (bold red) + XCTAssertTrue(html.contains(#"500"#)) + } + + func testLogFilePath() { + let log = "loading /usr/local/etc/config.yaml" + let html = body(SyntaxHighlighter.highlight(log, format: .log)) + XCTAssertTrue(html.contains(#"/usr/local/etc/config.yaml"#)) + } + } diff --git a/Shared/SyntaxHighlighter.swift b/Shared/SyntaxHighlighter.swift index 84839d9..2211bdc 100644 --- a/Shared/SyntaxHighlighter.swift +++ b/Shared/SyntaxHighlighter.swift @@ -7,7 +7,7 @@ import Foundation enum FileFormat { case json, yaml, toml, xml, mobileconfig, shell, powershell, python, ruby, go, rust, javascript, - markdown, hcl + markdown, hcl, log init?(pathExtension: String) { switch pathExtension.lowercased() { @@ -25,6 +25,7 @@ enum FileFormat { case "js", "jsx", "ts", "tsx", "mjs", "cjs": self = .javascript case "md", "markdown", "adoc": self = .markdown case "tf", "tfvars", "hcl": self = .hcl + case "log", "out", "err": self = .log default: return nil } } @@ -45,23 +46,26 @@ enum SyntaxHighlighter { return html } } - let tokens: [Token] + + let body: String switch format { - case .json: tokens = tokenizeJSON(source) - case .yaml: tokens = tokenizeYAML(source) - case .toml: tokens = tokenizeTOML(source) - case .xml, .mobileconfig: tokens = tokenizeXML(source) - case .shell: tokens = tokenizeShell(source) - case .powershell: tokens = tokenizePowerShell(source) - case .python: tokens = tokenizePython(source) - case .ruby: tokens = tokenizeRuby(source) - case .go: tokens = tokenizeGo(source) - case .rust: tokens = tokenizeRust(source) - case .javascript: tokens = tokenizeJavaScript(source) + case .json: body = renderTokens(tokenizeJSON(source)) + case .yaml: body = renderTokens(tokenizeYAML(source)) + case .toml: body = renderTokens(tokenizeTOML(source)) + case .xml, .mobileconfig: body = renderTokens(tokenizeXML(source)) + case .shell: body = renderTokens(tokenizeShell(source)) + case .powershell: body = renderTokens(tokenizePowerShell(source)) + case .python: body = renderTokens(tokenizePython(source)) + case .ruby: body = renderTokens(tokenizeRuby(source)) + case .go: body = renderTokens(tokenizeGo(source)) + case .rust: body = renderTokens(tokenizeRust(source)) + case .javascript: body = renderTokens(tokenizeJavaScript(source)) case .markdown: return renderMarkdown(source, dark: darkMode) - case .hcl: tokens = tokenizeHCL(source) + case .hcl: body = renderTokens(tokenizeHCL(source)) + case .log: body = renderLogHTML(source) } - return wrapHTML(renderTokens(tokens), dark: darkMode) + + return wrapHTML(body, dark: darkMode) } // MARK: - Mobileconfig Renderer @@ -737,6 +741,24 @@ enum SyntaxHighlighter { .keyword { color: \(t.xmlBool); } .operator { color: \(t.xmlComment); } .command { color: \(t.xmlAttrName); } + .logError { color: \(t.boolNo); font-weight: bold; } + .logWarn { color: \(t.scopeDevice); } + .logInfo { color: \(t.scopeUser); } + .logDebug { color: \(t.muted); font-style: italic; } + .logTimestamp { color: \(t.muted); font-style: italic; } + .logErrorMuted { color: \(t.boolNo); opacity: 0.6; } + .logWarnMuted { color: \(t.scopeDevice); opacity: 0.6; } + .logInfoMuted { color: \(t.scopeUser); opacity: 0.6; } + .logDebugMuted { color: \(t.muted); opacity: 0.6; font-style: italic; } + .lt { color: \(t.muted); font-style: italic; } + .le { color: \(t.boolNo); font-weight: bold; } + .lw { color: \(t.scopeDevice); } + .li { color: \(t.scopeUser); } + .ld { color: \(t.muted); font-style: italic; } + .ln { color: \(t.xmlNumber); } + .lk { color: \(t.xmlBool); } + .ls { color: \(t.xmlString); } + .lv { color: \(t.xmlTag); } \(body) @@ -749,7 +771,8 @@ enum SyntaxHighlighter { private struct Token { enum Kind: String { case plain, key, string, number, bool, comment, tag, attrName, attrValue, punctuation, - plistKey, plistValue, variable, keyword, `operator`, command + plistKey, plistValue, variable, keyword, `operator`, command, + logError, logWarn, logInfo, logDebug, logTimestamp } let text: String let kind: Kind @@ -1096,6 +1119,282 @@ enum SyntaxHighlighter { } } + // MARK: - Log Tokenizer (heuristic, line-by-line for performance) + + private static func tokenizeLog(_ src: String) -> [Token] { + // Thin wrapper for tests that inspect Token output. + // The live rendering path uses renderLogHTML() directly. + let regex = try! Regex( + #"((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})"# // 1: syslog timestamp + + #"|(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)"# // 2: ISO/common timestamp + + #"|(\[\d{2}/\w{3}/\d{4}[:\d ]+[+-]?\d{0,4}\])"# // 3: Apache CLF timestamp + + #"|\b((?:EMERG(?:ENCY)?|FATAL|CRIT(?:ICAL)?|ALERT):?)"# // 4: critical severity + + #"|\b((?:ERR(?:OR)?):?)"# // 5: error severity + + #"|\b((?:WARN(?:ING)?):?)"# // 6: warning severity + + #"|\b((?:NOTICE|INFO):?)"# // 7: info/notice severity + + #"|\b((?:DEBUG|TRACE|VERBOSE):?)"# // 8: debug severity + + #"|(\d{1,3}(?:\.\d{1,3}){3})"# // 9: IPv4 address + + #"|\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b"# // 10: HTTP method + + #"|\b([1-5]\d{2})\b"# // 11: HTTP status code + + #"|("(?:[^"\\]|\\.)*")"# // 12: double-quoted string + + #"|(/[\w./-]+)"# // 13: file path + + #"|\b(\d+(?:\.\d+)?(?:%|ms|[smhd]|[KMGT]i?[Bb])?)\b"# // 14: number + ) + let handler: (MatchResult) -> [Token]? = { match in + if let ts = match[1] { + return [Token(text: ts, kind: .logTimestamp)] + } else if let ts = match[2] { + return [Token(text: ts, kind: .logTimestamp)] + } else if let ts = match[3] { + return [Token(text: ts, kind: .logTimestamp)] + } else if let sev = match[4] { + return [Token(text: sev, kind: .logError)] + } else if let sev = match[5] { + return [Token(text: sev, kind: .logError)] + } else if let sev = match[6] { + return [Token(text: sev, kind: .logWarn)] + } else if let sev = match[7] { + return [Token(text: sev, kind: .logInfo)] + } else if let sev = match[8] { + return [Token(text: sev, kind: .logDebug)] + } else if let ip = match[9] { + return [Token(text: ip, kind: .number)] + } else if let method = match[10] { + return [Token(text: method, kind: .keyword)] + } else if let status = match[11] { + let code = Int(status) ?? 0 + let kind: Token.Kind = code >= 500 ? .logError : code >= 400 ? .logWarn : code >= 300 ? .logDebug : .number + return [Token(text: status, kind: kind)] + } else if let str = match[12] { + return [Token(text: str, kind: .string)] + } else if let path = match[13] { + return [Token(text: path, kind: .variable)] + } else if let num = match[14] { + return [Token(text: num, kind: .number)] + } + return nil + } + + // Process line-by-line to avoid O(n²) regex scanning on large files. + var tokens: [Token] = [] + tokens.reserveCapacity(src.utf8.count / 40) + var first = true + for line in src.split(separator: "\n", omittingEmptySubsequences: false) { + if first { first = false } else { tokens.append(Token(text: "\n", kind: .plain)) } + let lineStr = String(line) + tokens.append(contentsOf: tokenize(lineStr, regex: regex, handler: handler)) + } + return tokens + } + + // MARK: - Fast Log Renderer (NSRegularExpression, direct HTML) + + /// Renders log files as syntax-highlighted HTML. + /// For files ≤ 1 MB, uses full per-token highlighting (all 9 token types). + /// For larger files, drops bare numbers and file paths (the two noisiest, + /// least useful token types) to cut DOM elements by ~40%. + private static func renderLogHTML(_ src: String) -> String { + if src.utf8.count <= 1_048_576 { + return renderLogHTMLFull(src) + } + return renderLogHTMLFast(src) + } + + /// Reduced-token log highlighting for large files. + /// Uses the same single-pass NSRegularExpression approach as the full path + /// but with a smaller regex that omits bare numbers and file paths, + /// cutting total `` elements by ~40%. + private static func renderLogHTMLFast(_ src: String) -> String { + // Same regex as full path minus groups for file paths and bare numbers + let pattern = + #"((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})"# + + #"|(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)"# + + #"|(\[\d{2}/\w{3}/\d{4}[:\d ]+[+-]?\d{0,4}\])"# + + #"|\b((?:EMERG(?:ENCY)?|FATAL|CRIT(?:ICAL)?|ALERT):?)"# + + #"|\b((?:ERR(?:OR)?):?)"# + + #"|\b((?:WARN(?:ING)?):?)"# + + #"|\b((?:NOTICE|INFO):?)"# + + #"|\b((?:DEBUG|TRACE|VERBOSE):?)"# + + #"|(\d{1,3}(?:\.\d{1,3}){3})"# + + #"|\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b"# + + #"|\b([1-5]\d{2})\b"# + + #"|("(?:[^"\\]|\\.)*")"# + + let nsRegex = try! NSRegularExpression(pattern: pattern) + let nsStr = src as NSString + let totalLen = nsStr.length + + let spanOpen: [[UInt8]] = [ + Array(#""#.utf8), // 0: groups 1,2,3 — timestamp + Array(#""#.utf8), // 1: groups 4,5 — error + Array(#""#.utf8), // 2: group 6 — warn + Array(#""#.utf8), // 3: group 7 — info + Array(#""#.utf8), // 4: group 8 — debug + Array(#""#.utf8), // 5: group 9 — IP + Array(#""#.utf8), // 6: group 10 — HTTP method + Array(#""#.utf8), // 7: group 12 — string + ] + let spanClose: [UInt8] = Array("".utf8) + + let groupToSpan: [Int: Int] = [ + 1: 0, 2: 0, 3: 0, 4: 1, 5: 1, 6: 2, 7: 3, 8: 4, + 9: 5, 10: 6, 12: 7, + ] + + var html: [UInt8] = [] + html.reserveCapacity(src.utf8.count + src.utf8.count / 2) + + func appendEscaped(from loc: Int, length len: Int) { + guard len > 0 else { return } + let sub = nsStr.substring(with: NSRange(location: loc, length: len)) + for byte in sub.utf8 { + switch byte { + case 0x26: html.append(contentsOf: [0x26, 0x61, 0x6D, 0x70, 0x3B]) + case 0x3C: html.append(contentsOf: [0x26, 0x6C, 0x74, 0x3B]) + case 0x3E: html.append(contentsOf: [0x26, 0x67, 0x74, 0x3B]) + case 0x22: html.append(contentsOf: [0x26, 0x71, 0x75, 0x6F, 0x74, 0x3B]) + default: html.append(byte) + } + } + } + + let allMatches = nsRegex.matches(in: src, range: NSRange(location: 0, length: totalLen)) + var cursor = 0 + for m in allMatches { + let matchRange = m.range + if matchRange.location > cursor { + appendEscaped(from: cursor, length: matchRange.location - cursor) + } + + var spanIdx: Int? = nil + for (gi, si) in groupToSpan { + if m.range(at: gi).location != NSNotFound { + spanIdx = si + break + } + } + // HTTP status codes (group 11) + if spanIdx == nil && m.range(at: 11).location != NSNotFound { + let statusRange = m.range(at: 11) + let d0 = nsStr.character(at: statusRange.location) - 0x30 + spanIdx = d0 >= 5 ? 1 : d0 >= 4 ? 2 : d0 >= 3 ? 4 : 5 + } + + if let si = spanIdx { + html.append(contentsOf: spanOpen[si]) + appendEscaped(from: matchRange.location, length: matchRange.length) + html.append(contentsOf: spanClose) + } else { + appendEscaped(from: matchRange.location, length: matchRange.length) + } + + cursor = matchRange.location + matchRange.length + } + + if cursor < totalLen { + appendEscaped(from: cursor, length: totalLen - cursor) + } + + return String(bytes: html, encoding: .utf8) ?? "" + } + + /// Full per-token log highlighting for files ≤ 1 MB. + private static func renderLogHTMLFull(_ src: String) -> String { + let pattern = + #"((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})"# + + #"|(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)"# + + #"|(\[\d{2}/\w{3}/\d{4}[:\d ]+[+-]?\d{0,4}\])"# + + #"|\b((?:EMERG(?:ENCY)?|FATAL|CRIT(?:ICAL)?|ALERT):?)"# + + #"|\b((?:ERR(?:OR)?):?)"# + + #"|\b((?:WARN(?:ING)?):?)"# + + #"|\b((?:NOTICE|INFO):?)"# + + #"|\b((?:DEBUG|TRACE|VERBOSE):?)"# + + #"|(\d{1,3}(?:\.\d{1,3}){3})"# + + #"|\b(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b"# + + #"|\b([1-5]\d{2})\b"# + + #"|("(?:[^"\\]|\\.)*")"# + + #"|(/[\w./-]+)"# + + #"|\b(\d+(?:\.\d+)?(?:%|ms|[smhd]|[KMGT]i?[Bb])?)\b"# + + let nsRegex = try! NSRegularExpression(pattern: pattern) + let nsStr = src as NSString + let totalLen = nsStr.length + + // Pre-built UTF-8 byte sequences for span tags (short class names for size) + let spanOpen: [[UInt8]] = [ + Array(#""#.utf8), // 0: groups 1,2,3 — timestamp + Array(#""#.utf8), // 1: groups 4,5 — error + Array(#""#.utf8), // 2: group 6 — warn + Array(#""#.utf8), // 3: group 7 — info + Array(#""#.utf8), // 4: group 8 — debug + Array(#""#.utf8), // 5: groups 9,14 — number/IP + Array(#""#.utf8), // 6: group 10 — HTTP method + Array(#""#.utf8), // 7: group 12 — string + Array(#""#.utf8), // 8: group 13 — path + ] + let spanClose: [UInt8] = Array("".utf8) + + let groupToSpan: [Int: Int] = [ + 1: 0, 2: 0, 3: 0, 4: 1, 5: 1, 6: 2, 7: 3, 8: 4, + 9: 5, 10: 6, 12: 7, 13: 8, 14: 5, + ] + + var html: [UInt8] = [] + html.reserveCapacity(src.utf8.count * 2) + + func appendEscaped(from loc: Int, length len: Int) { + guard len > 0 else { return } + let sub = nsStr.substring(with: NSRange(location: loc, length: len)) + for byte in sub.utf8 { + switch byte { + case 0x26: html.append(contentsOf: [0x26, 0x61, 0x6D, 0x70, 0x3B]) + case 0x3C: html.append(contentsOf: [0x26, 0x6C, 0x74, 0x3B]) + case 0x3E: html.append(contentsOf: [0x26, 0x67, 0x74, 0x3B]) + case 0x22: html.append(contentsOf: [0x26, 0x71, 0x75, 0x6F, 0x74, 0x3B]) + default: html.append(byte) + } + } + } + + let allMatches = nsRegex.matches(in: src, range: NSRange(location: 0, length: totalLen)) + var cursor = 0 + for m in allMatches { + let matchRange = m.range + if matchRange.location > cursor { + appendEscaped(from: cursor, length: matchRange.location - cursor) + } + + var spanIdx: Int? = nil + for (gi, si) in groupToSpan { + if m.range(at: gi).location != NSNotFound { + spanIdx = si + break + } + } + if spanIdx == nil && m.range(at: 11).location != NSNotFound { + let statusRange = m.range(at: 11) + let d0 = nsStr.character(at: statusRange.location) - 0x30 + spanIdx = d0 >= 5 ? 1 : d0 >= 4 ? 2 : d0 >= 3 ? 4 : 5 + } + + if let si = spanIdx { + html.append(contentsOf: spanOpen[si]) + appendEscaped(from: matchRange.location, length: matchRange.length) + html.append(contentsOf: spanClose) + } else { + appendEscaped(from: matchRange.location, length: matchRange.length) + } + + cursor = matchRange.location + matchRange.length + } + + if cursor < totalLen { + appendEscaped(from: cursor, length: totalLen - cursor) + } + + return String(bytes: html, encoding: .utf8) ?? "" + } + // MARK: - Markdown Renderer private static func renderMarkdown(_ source: String, dark: Bool) -> String { @@ -1430,22 +1729,55 @@ enum SyntaxHighlighter { // MARK: - HTML Rendering private static func renderTokens(_ tokens: [Token]) -> String { - tokens.map { token in + // Estimate total size: each token is ~(text + 40 bytes span overhead) + let estimatedSize = tokens.reduce(0) { $0 + $1.text.utf8.count + 40 } + var html = "" + html.reserveCapacity(estimatedSize) + for token in tokens { let escaped = escapeHTML(token.text) switch token.kind { case .plain, .punctuation: - return escaped + html += escaped default: - return "\(escaped)" + html += "" + html += escaped + html += "" } - }.joined() + } + return html } static func escapeHTML(_ text: String) -> String { - text.replacingOccurrences(of: "&", with: "&") - .replacingOccurrences(of: "<", with: "<") - .replacingOccurrences(of: ">", with: ">") - .replacingOccurrences(of: "\"", with: """) + // Work at UTF-8 byte level — the four characters we escape (&<>") are all + // single-byte ASCII, so we can scan bytes and copy runs of safe bytes in bulk. + var utf8 = Array(text.utf8) + var result: [UInt8] = [] + result.reserveCapacity(utf8.count + utf8.count / 8) + var runStart = 0 + for i in 0.. " + // Flush preceding safe run + if i > runStart { + result.append(contentsOf: utf8[runStart.. String { @@ -1468,6 +1800,24 @@ enum SyntaxHighlighter { .keyword { color: #c586c0; } .operator { color: #abb2bf; } .command { color: #61afef; } + .logError { color: #ff453a; font-weight: bold; } + .logWarn { color: #ff9f0a; } + .logInfo { color: #0a84ff; } + .logDebug { color: #636366; font-style: italic; } + .logTimestamp { color: #636366; font-style: italic; } + .logErrorMuted { color: #ff6961; opacity: 0.7; } + .logWarnMuted { color: #ffb347; opacity: 0.7; } + .logInfoMuted { color: #6eb5ff; opacity: 0.7; } + .logDebugMuted { color: #636366; opacity: 0.7; font-style: italic; } + .lt { color: #636366; font-style: italic; } + .le { color: #ff453a; font-weight: bold; } + .lw { color: #ff9f0a; } + .li { color: #0a84ff; } + .ld { color: #636366; font-style: italic; } + .ln { color: #b5cea8; } + .lk { color: #c586c0; } + .ls { color: #ce9178; } + .lv { color: #d19a66; } """ } else { colors = """ @@ -1485,6 +1835,24 @@ enum SyntaxHighlighter { .keyword { color: #af00db; } .operator { color: #383a42; } .command { color: #4078f2; } + .logError { color: #dc2626; font-weight: bold; } + .logWarn { color: #ea580c; } + .logInfo { color: #2563eb; } + .logDebug { color: #94a3b8; font-style: italic; } + .logTimestamp { color: #94a3b8; font-style: italic; } + .logErrorMuted { color: #ef4444; opacity: 0.6; } + .logWarnMuted { color: #f97316; opacity: 0.6; } + .logInfoMuted { color: #60a5fa; opacity: 0.6; } + .logDebugMuted { color: #94a3b8; opacity: 0.6; font-style: italic; } + .lt { color: #94a3b8; font-style: italic; } + .le { color: #dc2626; font-weight: bold; } + .lw { color: #ea580c; } + .li { color: #2563eb; } + .ld { color: #94a3b8; font-style: italic; } + .ln { color: #098658; } + .lk { color: #af00db; } + .ls { color: #a31515; } + .lv { color: #e06c20; } """ } return """