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 """