git.iko.soy/parseable-macos
on master help sign in
Implement 8 additional performance optimizations for render path, live tail, syntax highlighting, and CSV export (#47)
commit0ec037841bbb119d4cb926f6a4f7c3bab1971af8 authoriko <mail@iko.soy> dateMon 16 Feb 2026, 03:19 (8 months ago) parentd1db137
8 files changed+213−117
ParseableViewer/Services/ParseableClient.swift+8−8 ParseableViewer/ViewModels/LiveTailViewModel.swift+54−24 ParseableViewer/ViewModels/QueryViewModel.swift+15−8 ParseableViewer/Views/LiveTailView.swift+18−7 ParseableViewer/Views/LogDetailView.swift+19−8 ParseableViewer/Views/LogTableView.swift+30−4 ParseableViewer/Views/SQLAutoComplete.swift+4−4 ParseableViewer/Views/SyntaxHighlighting.swift+65−54
ParseableViewer/Services/ParseableClient.swift+8−8
@@ -66,6 +66,7 @@ final class ParseableClient: Sendable {
6666 let username: String
6767 let password: String
6868 private let session: URLSession
69+ private let authHeader: String
6970
7071 private static let isoFormatter: ISO8601DateFormatter = {
7172 let f = ISO8601DateFormatter()
@@ -78,6 +79,13 @@ final class ParseableClient: Sendable {
7879 self.username = username
7980 self.password = password
8081
82+ let credentials = "\(username):\(password)"
83+ if let data = credentials.data(using: .utf8) {
84+ self.authHeader = "Basic \(data.base64EncodedString())"
85+ } else {
86+ self.authHeader = "Basic "
87+ }
88+
8189 let config = URLSessionConfiguration.default
8290 config.timeoutIntervalForRequest = 30
8391 config.timeoutIntervalForResource = 120
@@ -97,14 +105,6 @@ final class ParseableClient: Sendable {
97105 session.finishTasksAndInvalidate()
98106 }
99107
100− private var authHeader: String {
101− let credentials = "\(username):\(password)"
102− guard let data = credentials.data(using: .utf8) else {
103− return "Basic "
104− }
105− return "Basic \(data.base64EncodedString())"
106− }
107−
108108 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
109109 var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
110110 if let queryItems {
ParseableViewer/ViewModels/LiveTailViewModel.swift+54−24
@@ -28,6 +28,7 @@ final class LiveTailViewModel {
2828 nonisolated(unsafe) private var timer: Timer?
2929 private var lastTimestamp: Date?
3030 private var seenFingerprints: Set<String> = []
31+ private var allKnownKeys: Set<String> = []
3132 private var consecutiveErrors = 0
3233 private static let maxConsecutiveErrors = 5
3334
@@ -60,6 +61,7 @@ final class LiveTailViewModel {
6061 let record: LogRecord
6162 let displayTimestamp: String
6263 let summary: String
64+ let fingerprint: String
6365 }
6466
6567 struct ColumnFilter: Identifiable, Equatable, Sendable {
@@ -76,32 +78,46 @@ final class LiveTailViewModel {
7678 }
7779
7880 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
81+ private(set) var cachedFilteredRecords: [LogRecord] = []
7982 private(set) var filteredEntriesGeneration: Int = 0
8083
8184 private func rebuildFilteredEntries() {
82− var result = entries
83−
84− for filter in columnFilters {
85− result = result.filter { entry in
86− let recordValue = entry.record[filter.column]
87− let matches: Bool
88− if filter.value == nil || filter.value == .null {
89− matches = recordValue == nil || recordValue == .null
90− } else {
91− matches = recordValue == filter.value
85+ let hasColumnFilters = !columnFilters.isEmpty
86+ let hasTextFilter = !filterText.isEmpty
87+ let activeFilters = columnFilters
88+ let text = filterText
89+
90+ let result: [LiveTailEntry]
91+ if !hasColumnFilters && !hasTextFilter {
92+ result = entries
93+ } else {
94+ result = entries.filter { entry in
95+ if hasColumnFilters {
96+ for filter in activeFilters {
97+ let recordValue = entry.record[filter.column]
98+ let matches: Bool
99+ if filter.value == nil || filter.value == .null {
100+ matches = recordValue == nil || recordValue == .null
101+ } else {
102+ matches = recordValue == filter.value
103+ }
104+ if filter.exclude ? matches : !matches {
105+ return false
106+ }
107+ }
92108 }
93− return filter.exclude ? !matches : matches
94− }
95− }
96−
97− if !filterText.isEmpty {
98− result = result.filter { entry in
99− entry.summary.localizedCaseInsensitiveContains(filterText) ||
100− entry.record.values.contains { $0.displayString.localizedCaseInsensitiveContains(filterText) }
109+ if hasTextFilter {
110+ if !entry.summary.localizedCaseInsensitiveContains(text) &&
111+ !entry.record.values.contains(where: { $0.displayString.localizedCaseInsensitiveContains(text) }) {
112+ return false
113+ }
114+ }
115+ return true
101116 }
102117 }
103118
104119 cachedFilteredEntries = result
120+ cachedFilteredRecords = result.map { $0.record }
105121 filteredEntriesGeneration += 1
106122 }
107123
@@ -139,6 +155,7 @@ final class LiveTailViewModel {
139155 errorMessage = nil
140156 entries = []
141157 seenFingerprints = []
158+ allKnownKeys = []
142159 droppedCount = 0
143160 consecutiveErrors = 0
144161 lastPollTime = nil
@@ -172,6 +189,7 @@ final class LiveTailViewModel {
172189 func clear() {
173190 entries = []
174191 seenFingerprints = []
192+ allKnownKeys = []
175193 droppedCount = 0
176194 columns = []
177195 columnOrder = []
@@ -326,7 +344,8 @@ final class LiveTailViewModel {
326344 timestamp: timestamp,
327345 record: record,
328346 displayTimestamp: Self.displayFormatter.string(from: timestamp),
329− summary: summary
347+ summary: summary,
348+ fingerprint: fp
330349 ))
331350 }
332351
@@ -338,15 +357,26 @@ final class LiveTailViewModel {
338357 droppedCount += excess
339358 entries.removeFirst(excess)
340359
341− // Rebuild fingerprint set from remaining entries to bound memory
342− // and ensure dropped entries can be re-detected if they reappear.
343− seenFingerprints = Set(entries.map { Self.fingerprint(for: $0.record) })
360+ // Rebuild fingerprint set from stored values (no re-hashing)
361+ seenFingerprints = Set(entries.map { $0.fingerprint })
362+ }
363+
364+ // Only scan new records for new keys
365+ var hasNewKeys = false
366+ for entry in newEntries {
367+ for key in entry.record.keys {
368+ if allKnownKeys.insert(key).inserted {
369+ hasNewKeys = true
370+ }
371+ }
372+ }
373+ if hasNewKeys {
374+ updateColumns(stream: stream)
344375 }
345376
346− updateColumns(stream: stream)
377+ rebuildFilteredEntries()
347378 }
348379
349− rebuildFilteredEntries()
350380 lastTimestamp = now
351381 lastPollTime = now
352382 errorMessage = nil
ParseableViewer/ViewModels/QueryViewModel.swift+15−8
@@ -5,8 +5,14 @@ import SwiftUI
55final class QueryViewModel {
66 var sqlQuery = ""
77 var results: [LogRecord] = [] {
8− didSet { updateFilteredResults() }
8+ didSet {
9+ cachedSearchTexts = results.map { record in
10+ record.values.map { $0.displayString }.joined(separator: " ")
11+ }
12+ updateFilteredResults()
13+ }
914 }
15+ private var cachedSearchTexts: [String] = []
1016 var columns: [String] = []
1117 var columnOrder: [String] = []
1218 var hiddenColumns: Set<String> = []
@@ -266,10 +272,9 @@ final class QueryViewModel {
266272 if filterText.isEmpty {
267273 filteredResults = results
268274 } else {
269− filteredResults = results.filter { record in
270− record.values.contains { value in
271− value.displayString.localizedCaseInsensitiveContains(filterText)
272− }
275+ let text = filterText
276+ filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
277+ searchText.localizedCaseInsensitiveContains(text) ? record : nil
273278 }
274279 }
275280 }
@@ -453,17 +458,19 @@ final class QueryViewModel {
453458 static func buildCSV(records: [LogRecord], columns: [String]) -> String {
454459 guard !records.isEmpty, !columns.isEmpty else { return "" }
455460
456− var csv = columns.map { escapeCSV($0) }.joined(separator: ",") + "\n"
461+ var lines: [String] = []
462+ lines.reserveCapacity(records.count + 1)
463+ lines.append(columns.map { escapeCSV($0) }.joined(separator: ","))
457464
458465 for record in records {
459466 let row = columns.map { column in
460467 let value = record[column]?.exportString ?? ""
461468 return escapeCSV(value)
462469 }
463− csv += row.joined(separator: ",") + "\n"
470+ lines.append(row.joined(separator: ","))
464471 }
465472
466− return csv
473+ return lines.joined(separator: "\n") + "\n"
467474 }
468475
469476 private static func escapeCSV(_ value: String) -> String {
ParseableViewer/Views/LiveTailView.swift+18−7
@@ -12,6 +12,8 @@ struct LiveTailView: View {
1212 @State private var sortAscending = false
1313 @State private var columnWidths: [String: CGFloat] = [:]
1414 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
15+ @State private var cachedSeverities: [SeverityLevel] = []
16+ @State private var severityColumnSet: Set<String> = []
1517 @State private var liveTailSortTask: Task<Void, Never>?
1618
1719 var body: some View {
@@ -266,7 +268,7 @@ struct LiveTailView: View {
266268 }
267269 .onChange(of: viewModel.visibleColumns) { _, newColumns in
268270 // Compute widths for any newly visible columns
269− let records = viewModel.cachedFilteredEntries.map { $0.record }
271+ let records = viewModel.cachedFilteredRecords
270272 for col in newColumns where columnWidths[col] == nil {
271273 columnWidths[col] = idealColumnWidth(for: col, records: records)
272274 }
@@ -278,8 +280,11 @@ struct LiveTailView: View {
278280
279281 private func rebuildSortedEntries() {
280282 let entries = viewModel.cachedFilteredEntries
283+ let cols = viewModel.visibleColumns
281284 guard let col = sortColumn else {
282285 cachedSorted = entries
286+ cachedSeverities = entries.map { extractSeverity(from: $0.record) }
287+ severityColumnSet = buildSeverityColumnSet(columns: cols)
283288 return
284289 }
285290 liveTailSortTask?.cancel()
@@ -287,13 +292,17 @@ struct LiveTailView: View {
287292 liveTailSortTask = Task {
288293 try? await Task.sleep(for: .milliseconds(150))
289294 guard !Task.isCancelled else { return }
290− let sorted = entries.sorted { a, b in
291− let aVal = a.record[col] ?? .null
292− let bVal = b.record[col] ?? .null
293− return asc ? aVal < bVal : bVal < aVal
294− }
295+ let sorted = await Task.detached(priority: .userInitiated) {
296+ entries.sorted { a, b in
297+ let aVal = a.record[col] ?? .null
298+ let bVal = b.record[col] ?? .null
299+ return asc ? aVal < bVal : bVal < aVal
300+ }
301+ }.value
295302 guard !Task.isCancelled else { return }
296303 cachedSorted = sorted
304+ cachedSeverities = sorted.map { extractSeverity(from: $0.record) }
305+ severityColumnSet = buildSeverityColumnSet(columns: cols)
297306 }
298307 }
299308
@@ -309,6 +318,8 @@ struct LiveTailView: View {
309318 columnWidths: columnWidths,
310319 isSelected: selectedRecord == cachedSorted[index].record,
311320 isAlternate: index % 2 == 1,
321+ severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
322+ severityColumns: severityColumnSet,
312323 wrapText: wrapText,
313324 onCellFilter: { column, value, exclude in
314325 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
@@ -329,7 +340,7 @@ struct LiveTailView: View {
329340 sortColumn: $sortColumn,
330341 sortAscending: $sortAscending,
331342 columnWidths: $columnWidths,
332− records: viewModel.cachedFilteredEntries.map { $0.record },
343+ records: viewModel.cachedFilteredRecords,
333344 onMoveColumn: { from, to in
334345 viewModel.moveColumn(from, to: to)
335346 },
ParseableViewer/Views/LogDetailView.swift+19−8
@@ -87,8 +87,9 @@ struct FormattedRecordView: View {
8787 }
8888
8989 var body: some View {
90+ let keys = sortedKeys
9091 VStack(alignment: .leading, spacing: 1) {
91− ForEach(sortedKeys, id: \.self) { key in
92+ ForEach(Array(keys.enumerated()), id: \.element) { index, key in
9293 HStack(alignment: .top, spacing: 8) {
9394 Text(key)
9495 .font(.system(.caption, design: .monospaced))
@@ -105,8 +106,7 @@ struct FormattedRecordView: View {
105106 .padding(.vertical, 3)
106107 .padding(.horizontal, 8)
107108 .background(
108− sortedKeys.firstIndex(of: key).map { $0 % 2 == 1 }
109− ?? false ? Color.primary.opacity(0.03) : Color.clear
109+ index % 2 == 1 ? Color.primary.opacity(0.03) : Color.clear
110110 )
111111 }
112112 }
@@ -185,12 +185,23 @@ struct JSONValueView: View {
185185
186186struct RawJSONView: View {
187187 let record: LogRecord
188+ @State private var highlightedText: AttributedString?
188189
189190 var body: some View {
190− let json = JSONValue.object(record).prettyPrinted()
191− Text(JSONSyntaxHighlighter.highlight(json))
192− .textSelection(.enabled)
193− .padding(8)
194− .frame(maxWidth: .infinity, alignment: .leading)
191+ Group {
192+ if let highlighted = highlightedText {
193+ Text(highlighted)
194+ } else {
195+ Text(JSONValue.object(record).prettyPrinted())
196+ .font(.system(.caption, design: .monospaced))
197+ }
198+ }
199+ .textSelection(.enabled)
200+ .padding(8)
201+ .frame(maxWidth: .infinity, alignment: .leading)
202+ .task(id: record) {
203+ let json = JSONValue.object(record).prettyPrinted()
204+ highlightedText = JSONSyntaxHighlighter.highlight(json)
205+ }
195206 }
196207}
ParseableViewer/Views/LogTableView.swift+30−4
@@ -142,6 +142,17 @@ func severityRowTint(for level: SeverityLevel) -> Color? {
142142 }
143143}
144144
145+/// Builds the set of column names (preserving original casing) that are severity columns.
146+func buildSeverityColumnSet(columns: [String]) -> Set<String> {
147+ var result = Set<String>()
148+ for col in columns {
149+ if severityColumnNames.contains(col.lowercased()) {
150+ result.insert(col)
151+ }
152+ }
153+ return result
154+}
155+
145156// MARK: - LogTableView
146157
147158struct LogTableView: View {
@@ -156,6 +167,8 @@ struct LogTableView: View {
156167 @State private var sortAscending = false
157168 @State private var selectedIndex: Int?
158169 @State private var cachedSorted: [LogRecord] = []
170+ @State private var cachedSeverities: [SeverityLevel] = []
171+ @State private var severityColumnSet: Set<String> = []
159172 @State private var sortTask: Task<Void, Never>?
160173 @State private var widthTask: Task<Void, Never>?
161174 @State private var columnWidths: [String: CGFloat] = [:]
@@ -190,6 +203,8 @@ struct LogTableView: View {
190203 columnWidths: columnWidths,
191204 isSelected: selectedIndex == index,
192205 isAlternate: index % 2 == 1,
206+ severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
207+ severityColumns: severityColumnSet,
193208 wrapText: wrapText,
194209 onCellFilter: onCellFilter
195210 )
@@ -243,7 +258,12 @@ struct LogTableView: View {
243258 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
244259 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
245260 .onAppear {
246− columnWidths = computeColumnWidths(columns: columns, records: records)
261+ let cols = columns
262+ let recs = records
263+ widthTask = Task.detached(priority: .userInitiated) {
264+ let widths = computeColumnWidths(columns: cols, records: recs)
265+ await MainActor.run { columnWidths = widths }
266+ }
247267 rebuildSort()
248268 }
249269 }
@@ -271,6 +291,8 @@ struct LogTableView: View {
271291 }
272292 guard !Task.isCancelled else { return }
273293 cachedSorted = sorted
294+ cachedSeverities = sorted.map { extractSeverity(from: $0) }
295+ severityColumnSet = buildSeverityColumnSet(columns: columns)
274296 if let selectedRecord,
275297 let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
276298 selectedIndex = idx
@@ -290,6 +312,8 @@ struct LogTableView: View {
290312 } else {
291313 cachedSorted = records
292314 }
315+ cachedSeverities = cachedSorted.map { extractSeverity(from: $0) }
316+ severityColumnSet = buildSeverityColumnSet(columns: columns)
293317 if let selectedRecord,
294318 let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
295319 selectedIndex = idx
@@ -511,7 +535,7 @@ struct LogHeaderView: View {
511535 }
512536 .background(Color(nsColor: .controlBackgroundColor))
513537 .border(Color.secondary.opacity(0.2), width: 0.5)
514− .onChange(of: records.count) { _, _ in
538+ .onChange(of: records) { _, _ in
515539 uniqueValuesCache = [:]
516540 }
517541 }
@@ -569,6 +593,8 @@ struct LogRowView: View {
569593 let columnWidths: [String: CGFloat]
570594 let isSelected: Bool
571595 let isAlternate: Bool
596+ var severity: SeverityLevel = .unknown
597+ var severityColumns: Set<String> = []
572598 var wrapText: Bool = false
573599 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
574600
@@ -613,7 +639,7 @@ struct LogRowView: View {
613639 if isAlternate {
614640 Color.primary.opacity(0.02)
615641 }
616− if let tint = severityRowTint(for: extractSeverity(from: record)) {
642+ if let tint = severityRowTint(for: severity) {
617643 tint
618644 }
619645 }
@@ -623,7 +649,7 @@ struct LogRowView: View {
623649
624650 private func colorForValue(column: String, value: JSONValue?) -> Color {
625651 guard let value else { return .secondary }
626− if severityColumnNames.contains(column.lowercased()) {
652+ if severityColumns.contains(column) {
627653 return levelColor(for: value.displayString)
628654 }
629655 return .primary
ParseableViewer/Views/SQLAutoComplete.swift+4−4
@@ -108,10 +108,10 @@ enum SQLCompletionProvider {
108108 where field.name.uppercased().hasPrefix(uppercasePrefix) {
109109 items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
110110 }
111− for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
111+ for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
112112 items.append(SQLCompletionItem(text: fn, kind: .function))
113113 }
114− for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
114+ for kw in SQLSyntaxHighlighter.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
115115 items.append(SQLCompletionItem(text: kw, kind: .keyword))
116116 }
117117
@@ -121,10 +121,10 @@ enum SQLCompletionProvider {
121121 }
122122
123123 case .general:
124− for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
124+ for kw in SQLSyntaxHighlighter.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
125125 items.append(SQLCompletionItem(text: kw, kind: .keyword))
126126 }
127− for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
127+ for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
128128 items.append(SQLCompletionItem(text: fn, kind: .function))
129129 }
130130 for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
ParseableViewer/Views/SyntaxHighlighting.swift+65−54
@@ -44,6 +44,24 @@ enum SQLSyntaxHighlighter {
4444 "UPPER",
4545 ]
4646
47+ // Pre-sorted arrays for completions
48+ static let sortedKeywords: [String] = keywords.sorted()
49+ static let sortedFunctions: [String] = functions.sorted()
50+
51+ // Pre-compiled regexes (created once, reused every keystroke)
52+ private static let commentRegex = try! NSRegularExpression(pattern: "--[^\n]*", options: [.caseInsensitive])
53+ private static let singleQuoteRegex = try! NSRegularExpression(pattern: "'[^']*(?:''[^']*)*'", options: [.caseInsensitive])
54+ private static let doubleQuoteRegex = try! NSRegularExpression(pattern: "\"[^\"]*(?:\"\"[^\"]*)*\"", options: [.caseInsensitive])
55+ private static let numberRegex = try! NSRegularExpression(pattern: "\\b\\d+(?:\\.\\d+)?\\b", options: [])
56+ private static let keywordRegex: NSRegularExpression = {
57+ let pattern = "\\b(?:" + sortedKeywords.joined(separator: "|") + ")\\b"
58+ return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
59+ }()
60+ private static let functionRegex: NSRegularExpression = {
61+ let pattern = "\\b(?:" + sortedFunctions.joined(separator: "|") + ")\\b"
62+ return try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
63+ }()
64+
4765 /// Applies SQL syntax highlighting to an NSTextStorage.
4866 static func highlight(_ text: String, in textStorage: NSTextStorage, baseFont: NSFont) {
4967 let fullRange = NSRange(location: 0, length: textStorage.length)
@@ -59,42 +77,39 @@ enum SQLSyntaxHighlighter {
5977 var protectedRanges: [NSRange] = []
6078
6179 // 1. Comments (-- to end of line)
62− applyPattern("--[^\n]*", to: textStorage, text: nsText,
63− color: .secondaryLabelColor, protectedRanges: &protectedRanges)
80+ applyRegex(commentRegex, to: textStorage, text: nsText,
81+ color: .secondaryLabelColor, protectedRanges: &protectedRanges)
6482
6583 // 2. Single-quoted strings
66− applyPattern("'[^']*(?:''[^']*)*'", to: textStorage, text: nsText,
67− color: .systemRed, protectedRanges: &protectedRanges)
84+ applyRegex(singleQuoteRegex, to: textStorage, text: nsText,
85+ color: .systemRed, protectedRanges: &protectedRanges)
6886
6987 // 3. Double-quoted identifiers
70− applyPattern("\"[^\"]*(?:\"\"[^\"]*)*\"", to: textStorage, text: nsText,
71− color: .systemRed, protectedRanges: &protectedRanges)
88+ applyRegex(doubleQuoteRegex, to: textStorage, text: nsText,
89+ color: .systemRed, protectedRanges: &protectedRanges)
7290
7391 // 4. Numbers (outside protected ranges)
74− applyUnprotectedPattern("\\b\\d+(?:\\.\\d+)?\\b", to: textStorage, text: nsText,
75− color: .systemPurple, protectedRanges: protectedRanges)
92+ applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
93+ color: .systemPurple, protectedRanges: protectedRanges)
7694
7795 // 5. Keywords (outside protected ranges)
78− let keywordPattern = "\\b(?:" + keywords.sorted().joined(separator: "|") + ")\\b"
79− applyUnprotectedKeywordPattern(keywordPattern, to: textStorage, text: nsText,
80− color: .systemBlue, bold: true, baseFont: baseFont,
81− protectedRanges: protectedRanges)
96+ applyUnprotectedKeywordRegex(keywordRegex, to: textStorage, text: nsText,
97+ color: .systemBlue, bold: true, baseFont: baseFont,
98+ protectedRanges: protectedRanges)
8299
83100 // 6. Functions (outside protected ranges)
84− let functionPattern = "\\b(?:" + functions.sorted().joined(separator: "|") + ")\\b"
85− applyUnprotectedKeywordPattern(functionPattern, to: textStorage, text: nsText,
86− color: .systemTeal, bold: false, baseFont: baseFont,
87− protectedRanges: protectedRanges)
101+ applyUnprotectedKeywordRegex(functionRegex, to: textStorage, text: nsText,
102+ color: .systemTeal, bold: false, baseFont: baseFont,
103+ protectedRanges: protectedRanges)
88104 }
89105
90− private static func applyPattern(
91− _ pattern: String,
106+ private static func applyRegex(
107+ _ regex: NSRegularExpression,
92108 to textStorage: NSTextStorage,
93109 text: NSString,
94110 color: NSColor,
95111 protectedRanges: inout [NSRange]
96112 ) {
97− guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return }
98113 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
99114 for match in matches {
100115 textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
@@ -102,22 +117,21 @@ enum SQLSyntaxHighlighter {
102117 }
103118 }
104119
105− private static func applyUnprotectedPattern(
106− _ pattern: String,
120+ private static func applyUnprotectedRegex(
121+ _ regex: NSRegularExpression,
107122 to textStorage: NSTextStorage,
108123 text: NSString,
109124 color: NSColor,
110125 protectedRanges: [NSRange]
111126 ) {
112− guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
113127 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
114128 for match in matches where !isProtected(match.range, by: protectedRanges) {
115129 textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
116130 }
117131 }
118132
119− private static func applyUnprotectedKeywordPattern(
120− _ pattern: String,
133+ private static func applyUnprotectedKeywordRegex(
134+ _ regex: NSRegularExpression,
121135 to textStorage: NSTextStorage,
122136 text: NSString,
123137 color: NSColor,
@@ -125,7 +139,6 @@ enum SQLSyntaxHighlighter {
125139 baseFont: NSFont,
126140 protectedRanges: [NSRange]
127141 ) {
128− guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return }
129142 let boldFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .bold)
130143 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
131144 for match in matches where !isProtected(match.range, by: protectedRanges) {
@@ -151,6 +164,12 @@ enum SQLSyntaxHighlighter {
151164
152165enum JSONSyntaxHighlighter {
153166
167+ // Pre-compiled regexes (created once, reused every call)
168+ private static let stringRegex = try! NSRegularExpression(pattern: "\"(?:[^\"\\\\]|\\\\.)*\"", options: [])
169+ private static let boolRegex = try! NSRegularExpression(pattern: "\\b(true|false)\\b", options: [])
170+ private static let nullRegex = try! NSRegularExpression(pattern: "\\bnull\\b", options: [])
171+ private static let numberRegex = try! NSRegularExpression(pattern: "-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b", options: [])
172+
154173 /// Creates a syntax-highlighted AttributedString from a JSON string.
155174 static func highlight(_ json: String, font: NSFont? = nil) -> AttributedString {
156175 let resolvedFont = font ?? NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
@@ -168,45 +187,37 @@ enum JSONSyntaxHighlighter {
168187 var stringRanges: [NSRange] = []
169188
170189 // 1. Double-quoted strings — distinguish keys from values
171− if let regex = try? NSRegularExpression(pattern: "\"(?:[^\"\\\\]|\\\\.)*\"", options: []) {
172− let matches = regex.matches(in: json, range: fullRange)
173− for match in matches {
174− stringRanges.append(match.range)
175−
176− let isKey = isJSONKey(after: match.range, in: nsString)
177− let color: NSColor = isKey ? .systemCyan : .systemRed
178− attributed.addAttribute(.foregroundColor, value: color, range: match.range)
179− if isKey {
180− attributed.addAttribute(
181− .font,
182− value: NSFont.monospacedSystemFont(ofSize: resolvedFont.pointSize, weight: .medium),
183− range: match.range)
184− }
190+ let matches = stringRegex.matches(in: json, range: fullRange)
191+ for match in matches {
192+ stringRanges.append(match.range)
193+
194+ let isKey = isJSONKey(after: match.range, in: nsString)
195+ let color: NSColor = isKey ? .systemCyan : .systemRed
196+ attributed.addAttribute(.foregroundColor, value: color, range: match.range)
197+ if isKey {
198+ attributed.addAttribute(
199+ .font,
200+ value: NSFont.monospacedSystemFont(ofSize: resolvedFont.pointSize, weight: .medium),
201+ range: match.range)
185202 }
186203 }
187204
188205 // 2. Booleans (outside strings)
189− if let regex = try? NSRegularExpression(pattern: "\\b(true|false)\\b", options: []) {
190− for match in regex.matches(in: json, range: fullRange)
191− where !isInString(match.range, stringRanges: stringRanges) {
192− attributed.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range)
193− }
206+ for match in boolRegex.matches(in: json, range: fullRange)
207+ where !isInString(match.range, stringRanges: stringRanges) {
208+ attributed.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range)
194209 }
195210
196211 // 3. Null (outside strings)
197− if let regex = try? NSRegularExpression(pattern: "\\bnull\\b", options: []) {
198− for match in regex.matches(in: json, range: fullRange)
199− where !isInString(match.range, stringRanges: stringRanges) {
200− attributed.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: match.range)
201− }
212+ for match in nullRegex.matches(in: json, range: fullRange)
213+ where !isInString(match.range, stringRanges: stringRanges) {
214+ attributed.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: match.range)
202215 }
203216
204217 // 4. Numbers (outside strings)
205− if let regex = try? NSRegularExpression(pattern: "-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b", options: []) {
206− for match in regex.matches(in: json, range: fullRange)
207− where !isInString(match.range, stringRanges: stringRanges) {
208− attributed.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range)
209− }
218+ for match in numberRegex.matches(in: json, range: fullRange)
219+ where !isInString(match.range, stringRanges: stringRanges) {
220+ attributed.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range)
210221 }
211222
212223 return AttributedString(attributed)