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)
0ec0378iko
ParseableViewer/Services/ParseableClient.swift+8−8
@@ -66,6 +66,7 @@ final class ParseableClient: Sendable {
66 let username: String
66 let username: String
67 let password: String
67 let password: String
68 private let session: URLSession
68 private let session: URLSession
69+ private let authHeader: String
69
70
70 private static let isoFormatter: ISO8601DateFormatter = {
71 private static let isoFormatter: ISO8601DateFormatter = {
71 let f = ISO8601DateFormatter()
72 let f = ISO8601DateFormatter()
@@ -78,6 +79,13 @@ final class ParseableClient: Sendable {
78 self.username = username
79 self.username = username
79 self.password = password
80 self.password = password
80
81
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+
81 let config = URLSessionConfiguration.default
89 let config = URLSessionConfiguration.default
82 config.timeoutIntervalForRequest = 30
90 config.timeoutIntervalForRequest = 30
83 config.timeoutIntervalForResource = 120
91 config.timeoutIntervalForResource = 120
@@ -97,14 +105,6 @@ final class ParseableClient: Sendable {
97 session.finishTasksAndInvalidate()
105 session.finishTasksAndInvalidate()
98 }
106 }
99
107
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−
108 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
108 private func buildRequest(method: String, path: String, body: Data? = nil, queryItems: [URLQueryItem]? = nil) throws -> URLRequest {
109 var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
109 var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)
110 if let queryItems {
110 if let queryItems {
ParseableViewer/ViewModels/LiveTailViewModel.swift+54−24
@@ -28,6 +28,7 @@ final class LiveTailViewModel {
28 nonisolated(unsafe) private var timer: Timer?
28 nonisolated(unsafe) private var timer: Timer?
29 private var lastTimestamp: Date?
29 private var lastTimestamp: Date?
30 private var seenFingerprints: Set<String> = []
30 private var seenFingerprints: Set<String> = []
31+ private var allKnownKeys: Set<String> = []
31 private var consecutiveErrors = 0
32 private var consecutiveErrors = 0
32 private static let maxConsecutiveErrors = 5
33 private static let maxConsecutiveErrors = 5
33
34
@@ -60,6 +61,7 @@ final class LiveTailViewModel {
60 let record: LogRecord
61 let record: LogRecord
61 let displayTimestamp: String
62 let displayTimestamp: String
62 let summary: String
63 let summary: String
64+ let fingerprint: String
63 }
65 }
64
66
65 struct ColumnFilter: Identifiable, Equatable, Sendable {
67 struct ColumnFilter: Identifiable, Equatable, Sendable {
@@ -76,32 +78,46 @@ final class LiveTailViewModel {
76 }
78 }
77
79
78 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
80 private(set) var cachedFilteredEntries: [LiveTailEntry] = []
81+ private(set) var cachedFilteredRecords: [LogRecord] = []
79 private(set) var filteredEntriesGeneration: Int = 0
82 private(set) var filteredEntriesGeneration: Int = 0
80
83
81 private func rebuildFilteredEntries() {
84 private func rebuildFilteredEntries() {
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 {
82− var result = entries
94+ result = entries.filter { entry in
83−
95+ if hasColumnFilters {
84− for filter in columnFilters {
96+ for filter in activeFilters {
85− result = result.filter { entry in
86− let recordValue = entry.record[filter.column]
97+ let recordValue = entry.record[filter.column]
87− let matches: Bool
98+ let matches: Bool
88− if filter.value == nil || filter.value == .null {
99+ if filter.value == nil || filter.value == .null {
89− matches = recordValue == nil || recordValue == .null
100+ matches = recordValue == nil || recordValue == .null
90− } else {
101+ } else {
91− matches = recordValue == filter.value
102+ matches = recordValue == filter.value
103+ }
104+ if filter.exclude ? matches : !matches {
105+ return false
106+ }
107+ }
92 }
108 }
93− return filter.exclude ? !matches : matches
109+ if hasTextFilter {
94− }
95− }
96−
97− if !filterText.isEmpty {
98− result = result.filter { entry in
99− entry.summary.localizedCaseInsensitiveContains(filterText) ||
110+ if !entry.summary.localizedCaseInsensitiveContains(text) &&
100− entry.record.values.contains { $0.displayString.localizedCaseInsensitiveContains(filterText) }
111+ !entry.record.values.contains(where: { $0.displayString.localizedCaseInsensitiveContains(text) }) {
112+ return false
113+ }
114+ }
115+ return true
101 }
116 }
102 }
117 }
103
118
104 cachedFilteredEntries = result
119 cachedFilteredEntries = result
120+ cachedFilteredRecords = result.map { $0.record }
105 filteredEntriesGeneration += 1
121 filteredEntriesGeneration += 1
106 }
122 }
107
123
@@ -139,6 +155,7 @@ final class LiveTailViewModel {
139 errorMessage = nil
155 errorMessage = nil
140 entries = []
156 entries = []
141 seenFingerprints = []
157 seenFingerprints = []
158+ allKnownKeys = []
142 droppedCount = 0
159 droppedCount = 0
143 consecutiveErrors = 0
160 consecutiveErrors = 0
144 lastPollTime = nil
161 lastPollTime = nil
@@ -172,6 +189,7 @@ final class LiveTailViewModel {
172 func clear() {
189 func clear() {
173 entries = []
190 entries = []
174 seenFingerprints = []
191 seenFingerprints = []
192+ allKnownKeys = []
175 droppedCount = 0
193 droppedCount = 0
176 columns = []
194 columns = []
177 columnOrder = []
195 columnOrder = []
@@ -326,7 +344,8 @@ final class LiveTailViewModel {
326 timestamp: timestamp,
344 timestamp: timestamp,
327 record: record,
345 record: record,
328 displayTimestamp: Self.displayFormatter.string(from: timestamp),
346 displayTimestamp: Self.displayFormatter.string(from: timestamp),
329− summary: summary
347+ summary: summary,
348+ fingerprint: fp
330 ))
349 ))
331 }
350 }
332
351
@@ -338,15 +357,26 @@ final class LiveTailViewModel {
338 droppedCount += excess
357 droppedCount += excess
339 entries.removeFirst(excess)
358 entries.removeFirst(excess)
340
359
341− // Rebuild fingerprint set from remaining entries to bound memory
360+ // Rebuild fingerprint set from stored values (no re-hashing)
342− // and ensure dropped entries can be re-detected if they reappear.
343− seenFingerprints = Set(entries.map { Self.fingerprint(for: $0.record) })
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)
344 }
375 }
345
376
346− updateColumns(stream: stream)
377+ rebuildFilteredEntries()
347 }
378 }
348
379
349− rebuildFilteredEntries()
350 lastTimestamp = now
380 lastTimestamp = now
351 lastPollTime = now
381 lastPollTime = now
352 errorMessage = nil
382 errorMessage = nil
ParseableViewer/ViewModels/QueryViewModel.swift+15−8
@@ -5,8 +5,14 @@ import SwiftUI
5final class QueryViewModel {
5final class QueryViewModel {
6 var sqlQuery = ""
6 var sqlQuery = ""
7 var results: [LogRecord] = [] {
7 var results: [LogRecord] = [] {
8+ didSet {
9+ cachedSearchTexts = results.map { record in
10+ record.values.map { $0.displayString }.joined(separator: " ")
11+ }
8− didSet { updateFilteredResults() }
12+ updateFilteredResults()
13+ }
9 }
14 }
15+ private var cachedSearchTexts: [String] = []
10 var columns: [String] = []
16 var columns: [String] = []
11 var columnOrder: [String] = []
17 var columnOrder: [String] = []
12 var hiddenColumns: Set<String> = []
18 var hiddenColumns: Set<String> = []
@@ -266,10 +272,9 @@ final class QueryViewModel {
266 if filterText.isEmpty {
272 if filterText.isEmpty {
267 filteredResults = results
273 filteredResults = results
268 } else {
274 } else {
275+ let text = filterText
269− filteredResults = results.filter { record in
276+ filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
270− record.values.contains { value in
271− value.displayString.localizedCaseInsensitiveContains(filterText)
277+ searchText.localizedCaseInsensitiveContains(text) ? record : nil
272− }
273 }
278 }
274 }
279 }
275 }
280 }
@@ -453,17 +458,19 @@ final class QueryViewModel {
453 static func buildCSV(records: [LogRecord], columns: [String]) -> String {
458 static func buildCSV(records: [LogRecord], columns: [String]) -> String {
454 guard !records.isEmpty, !columns.isEmpty else { return "" }
459 guard !records.isEmpty, !columns.isEmpty else { return "" }
455
460
461+ var lines: [String] = []
462+ lines.reserveCapacity(records.count + 1)
456− var csv = columns.map { escapeCSV($0) }.joined(separator: ",") + "\n"
463+ lines.append(columns.map { escapeCSV($0) }.joined(separator: ","))
457
464
458 for record in records {
465 for record in records {
459 let row = columns.map { column in
466 let row = columns.map { column in
460 let value = record[column]?.exportString ?? ""
467 let value = record[column]?.exportString ?? ""
461 return escapeCSV(value)
468 return escapeCSV(value)
462 }
469 }
463− csv += row.joined(separator: ",") + "\n"
470+ lines.append(row.joined(separator: ","))
464 }
471 }
465
472
466− return csv
473+ return lines.joined(separator: "\n") + "\n"
467 }
474 }
468
475
469 private static func escapeCSV(_ value: String) -> String {
476 private static func escapeCSV(_ value: String) -> String {
ParseableViewer/Views/LiveTailView.swift+18−7
@@ -12,6 +12,8 @@ struct LiveTailView: View {
12 @State private var sortAscending = false
12 @State private var sortAscending = false
13 @State private var columnWidths: [String: CGFloat] = [:]
13 @State private var columnWidths: [String: CGFloat] = [:]
14 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
14 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
15+ @State private var cachedSeverities: [SeverityLevel] = []
16+ @State private var severityColumnSet: Set<String> = []
15 @State private var liveTailSortTask: Task<Void, Never>?
17 @State private var liveTailSortTask: Task<Void, Never>?
16
18
17 var body: some View {
19 var body: some View {
@@ -266,7 +268,7 @@ struct LiveTailView: View {
266 }
268 }
267 .onChange(of: viewModel.visibleColumns) { _, newColumns in
269 .onChange(of: viewModel.visibleColumns) { _, newColumns in
268 // Compute widths for any newly visible columns
270 // Compute widths for any newly visible columns
269− let records = viewModel.cachedFilteredEntries.map { $0.record }
271+ let records = viewModel.cachedFilteredRecords
270 for col in newColumns where columnWidths[col] == nil {
272 for col in newColumns where columnWidths[col] == nil {
271 columnWidths[col] = idealColumnWidth(for: col, records: records)
273 columnWidths[col] = idealColumnWidth(for: col, records: records)
272 }
274 }
@@ -278,8 +280,11 @@ struct LiveTailView: View {
278
280
279 private func rebuildSortedEntries() {
281 private func rebuildSortedEntries() {
280 let entries = viewModel.cachedFilteredEntries
282 let entries = viewModel.cachedFilteredEntries
283+ let cols = viewModel.visibleColumns
281 guard let col = sortColumn else {
284 guard let col = sortColumn else {
282 cachedSorted = entries
285 cachedSorted = entries
286+ cachedSeverities = entries.map { extractSeverity(from: $0.record) }
287+ severityColumnSet = buildSeverityColumnSet(columns: cols)
283 return
288 return
284 }
289 }
285 liveTailSortTask?.cancel()
290 liveTailSortTask?.cancel()
@@ -287,13 +292,17 @@ struct LiveTailView: View {
287 liveTailSortTask = Task {
292 liveTailSortTask = Task {
288 try? await Task.sleep(for: .milliseconds(150))
293 try? await Task.sleep(for: .milliseconds(150))
289 guard !Task.isCancelled else { return }
294 guard !Task.isCancelled else { return }
295+ let sorted = await Task.detached(priority: .userInitiated) {
290− let sorted = entries.sorted { a, b in
296+ entries.sorted { a, b in
291− let aVal = a.record[col] ?? .null
297+ let aVal = a.record[col] ?? .null
292− let bVal = b.record[col] ?? .null
298+ let bVal = b.record[col] ?? .null
293− return asc ? aVal < bVal : bVal < aVal
299+ return asc ? aVal < bVal : bVal < aVal
294− }
300+ }
301+ }.value
295 guard !Task.isCancelled else { return }
302 guard !Task.isCancelled else { return }
296 cachedSorted = sorted
303 cachedSorted = sorted
304+ cachedSeverities = sorted.map { extractSeverity(from: $0.record) }
305+ severityColumnSet = buildSeverityColumnSet(columns: cols)
297 }
306 }
298 }
307 }
299
308
@@ -309,6 +318,8 @@ struct LiveTailView: View {
309 columnWidths: columnWidths,
318 columnWidths: columnWidths,
310 isSelected: selectedRecord == cachedSorted[index].record,
319 isSelected: selectedRecord == cachedSorted[index].record,
311 isAlternate: index % 2 == 1,
320 isAlternate: index % 2 == 1,
321+ severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
322+ severityColumns: severityColumnSet,
312 wrapText: wrapText,
323 wrapText: wrapText,
313 onCellFilter: { column, value, exclude in
324 onCellFilter: { column, value, exclude in
314 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
325 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
@@ -329,7 +340,7 @@ struct LiveTailView: View {
329 sortColumn: $sortColumn,
340 sortColumn: $sortColumn,
330 sortAscending: $sortAscending,
341 sortAscending: $sortAscending,
331 columnWidths: $columnWidths,
342 columnWidths: $columnWidths,
332− records: viewModel.cachedFilteredEntries.map { $0.record },
343+ records: viewModel.cachedFilteredRecords,
333 onMoveColumn: { from, to in
344 onMoveColumn: { from, to in
334 viewModel.moveColumn(from, to: to)
345 viewModel.moveColumn(from, to: to)
335 },
346 },
ParseableViewer/Views/LogDetailView.swift+19−8
@@ -87,8 +87,9 @@ struct FormattedRecordView: View {
87 }
87 }
88
88
89 var body: some View {
89 var body: some View {
90+ let keys = sortedKeys
90 VStack(alignment: .leading, spacing: 1) {
91 VStack(alignment: .leading, spacing: 1) {
91− ForEach(sortedKeys, id: \.self) { key in
92+ ForEach(Array(keys.enumerated()), id: \.element) { index, key in
92 HStack(alignment: .top, spacing: 8) {
93 HStack(alignment: .top, spacing: 8) {
93 Text(key)
94 Text(key)
94 .font(.system(.caption, design: .monospaced))
95 .font(.system(.caption, design: .monospaced))
@@ -105,8 +106,7 @@ struct FormattedRecordView: View {
105 .padding(.vertical, 3)
106 .padding(.vertical, 3)
106 .padding(.horizontal, 8)
107 .padding(.horizontal, 8)
107 .background(
108 .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
110 )
110 )
111 }
111 }
112 }
112 }
@@ -185,12 +185,23 @@ struct JSONValueView: View {
185
185
186struct RawJSONView: View {
186struct RawJSONView: View {
187 let record: LogRecord
187 let record: LogRecord
188+ @State private var highlightedText: AttributedString?
188
189
189 var body: some View {
190 var body: some View {
191+ Group {
192+ if let highlighted = highlightedText {
193+ Text(highlighted)
194+ } else {
190− let json = JSONValue.object(record).prettyPrinted()
195+ Text(JSONValue.object(record).prettyPrinted())
191− Text(JSONSyntaxHighlighter.highlight(json))
196+ .font(.system(.caption, design: .monospaced))
197+ }
198+ }
192− .textSelection(.enabled)
199+ .textSelection(.enabled)
193− .padding(8)
200+ .padding(8)
194− .frame(maxWidth: .infinity, alignment: .leading)
201+ .frame(maxWidth: .infinity, alignment: .leading)
202+ .task(id: record) {
203+ let json = JSONValue.object(record).prettyPrinted()
204+ highlightedText = JSONSyntaxHighlighter.highlight(json)
205+ }
195 }
206 }
196}
207}
ParseableViewer/Views/LogTableView.swift+30−4
@@ -142,6 +142,17 @@ func severityRowTint(for level: SeverityLevel) -> Color? {
142 }
142 }
143}
143}
144
144
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+
145// MARK: - LogTableView
156// MARK: - LogTableView
146
157
147struct LogTableView: View {
158struct LogTableView: View {
@@ -156,6 +167,8 @@ struct LogTableView: View {
156 @State private var sortAscending = false
167 @State private var sortAscending = false
157 @State private var selectedIndex: Int?
168 @State private var selectedIndex: Int?
158 @State private var cachedSorted: [LogRecord] = []
169 @State private var cachedSorted: [LogRecord] = []
170+ @State private var cachedSeverities: [SeverityLevel] = []
171+ @State private var severityColumnSet: Set<String> = []
159 @State private var sortTask: Task<Void, Never>?
172 @State private var sortTask: Task<Void, Never>?
160 @State private var widthTask: Task<Void, Never>?
173 @State private var widthTask: Task<Void, Never>?
161 @State private var columnWidths: [String: CGFloat] = [:]
174 @State private var columnWidths: [String: CGFloat] = [:]
@@ -190,6 +203,8 @@ struct LogTableView: View {
190 columnWidths: columnWidths,
203 columnWidths: columnWidths,
191 isSelected: selectedIndex == index,
204 isSelected: selectedIndex == index,
192 isAlternate: index % 2 == 1,
205 isAlternate: index % 2 == 1,
206+ severity: index < cachedSeverities.count ? cachedSeverities[index] : .unknown,
207+ severityColumns: severityColumnSet,
193 wrapText: wrapText,
208 wrapText: wrapText,
194 onCellFilter: onCellFilter
209 onCellFilter: onCellFilter
195 )
210 )
@@ -243,7 +258,12 @@ struct LogTableView: View {
243 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
258 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
244 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
259 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
245 .onAppear {
260 .onAppear {
261+ let cols = columns
262+ let recs = records
263+ widthTask = Task.detached(priority: .userInitiated) {
246− columnWidths = computeColumnWidths(columns: columns, records: records)
264+ let widths = computeColumnWidths(columns: cols, records: recs)
265+ await MainActor.run { columnWidths = widths }
266+ }
247 rebuildSort()
267 rebuildSort()
248 }
268 }
249 }
269 }
@@ -271,6 +291,8 @@ struct LogTableView: View {
271 }
291 }
272 guard !Task.isCancelled else { return }
292 guard !Task.isCancelled else { return }
273 cachedSorted = sorted
293 cachedSorted = sorted
294+ cachedSeverities = sorted.map { extractSeverity(from: $0) }
295+ severityColumnSet = buildSeverityColumnSet(columns: columns)
274 if let selectedRecord,
296 if let selectedRecord,
275 let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
297 let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
276 selectedIndex = idx
298 selectedIndex = idx
@@ -290,6 +312,8 @@ struct LogTableView: View {
290 } else {
312 } else {
291 cachedSorted = records
313 cachedSorted = records
292 }
314 }
315+ cachedSeverities = cachedSorted.map { extractSeverity(from: $0) }
316+ severityColumnSet = buildSeverityColumnSet(columns: columns)
293 if let selectedRecord,
317 if let selectedRecord,
294 let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
318 let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
295 selectedIndex = idx
319 selectedIndex = idx
@@ -511,7 +535,7 @@ struct LogHeaderView: View {
511 }
535 }
512 .background(Color(nsColor: .controlBackgroundColor))
536 .background(Color(nsColor: .controlBackgroundColor))
513 .border(Color.secondary.opacity(0.2), width: 0.5)
537 .border(Color.secondary.opacity(0.2), width: 0.5)
514− .onChange(of: records.count) { _, _ in
538+ .onChange(of: records) { _, _ in
515 uniqueValuesCache = [:]
539 uniqueValuesCache = [:]
516 }
540 }
517 }
541 }
@@ -569,6 +593,8 @@ struct LogRowView: View {
569 let columnWidths: [String: CGFloat]
593 let columnWidths: [String: CGFloat]
570 let isSelected: Bool
594 let isSelected: Bool
571 let isAlternate: Bool
595 let isAlternate: Bool
596+ var severity: SeverityLevel = .unknown
597+ var severityColumns: Set<String> = []
572 var wrapText: Bool = false
598 var wrapText: Bool = false
573 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
599 var onCellFilter: ((_ column: String, _ value: JSONValue?, _ exclude: Bool) -> Void)?
574
600
@@ -613,7 +639,7 @@ struct LogRowView: View {
613 if isAlternate {
639 if isAlternate {
614 Color.primary.opacity(0.02)
640 Color.primary.opacity(0.02)
615 }
641 }
616− if let tint = severityRowTint(for: extractSeverity(from: record)) {
642+ if let tint = severityRowTint(for: severity) {
617 tint
643 tint
618 }
644 }
619 }
645 }
@@ -623,7 +649,7 @@ struct LogRowView: View {
623
649
624 private func colorForValue(column: String, value: JSONValue?) -> Color {
650 private func colorForValue(column: String, value: JSONValue?) -> Color {
625 guard let value else { return .secondary }
651 guard let value else { return .secondary }
626− if severityColumnNames.contains(column.lowercased()) {
652+ if severityColumns.contains(column) {
627 return levelColor(for: value.displayString)
653 return levelColor(for: value.displayString)
628 }
654 }
629 return .primary
655 return .primary
ParseableViewer/Views/SQLAutoComplete.swift+4−4
@@ -108,10 +108,10 @@ enum SQLCompletionProvider {
108 where field.name.uppercased().hasPrefix(uppercasePrefix) {
108 where field.name.uppercased().hasPrefix(uppercasePrefix) {
109 items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
109 items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
110 }
110 }
111− for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
111+ for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
112 items.append(SQLCompletionItem(text: fn, kind: .function))
112 items.append(SQLCompletionItem(text: fn, kind: .function))
113 }
113 }
114− for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
114+ for kw in SQLSyntaxHighlighter.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
115 items.append(SQLCompletionItem(text: kw, kind: .keyword))
115 items.append(SQLCompletionItem(text: kw, kind: .keyword))
116 }
116 }
117
117
@@ -121,10 +121,10 @@ enum SQLCompletionProvider {
121 }
121 }
122
122
123 case .general:
123 case .general:
124− for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
124+ for kw in SQLSyntaxHighlighter.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
125 items.append(SQLCompletionItem(text: kw, kind: .keyword))
125 items.append(SQLCompletionItem(text: kw, kind: .keyword))
126 }
126 }
127− for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
127+ for fn in SQLSyntaxHighlighter.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
128 items.append(SQLCompletionItem(text: fn, kind: .function))
128 items.append(SQLCompletionItem(text: fn, kind: .function))
129 }
129 }
130 for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
130 for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
ParseableViewer/Views/SyntaxHighlighting.swift+65−54
@@ -44,6 +44,24 @@ enum SQLSyntaxHighlighter {
44 "UPPER",
44 "UPPER",
45 ]
45 ]
46
46
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+
47 /// Applies SQL syntax highlighting to an NSTextStorage.
65 /// Applies SQL syntax highlighting to an NSTextStorage.
48 static func highlight(_ text: String, in textStorage: NSTextStorage, baseFont: NSFont) {
66 static func highlight(_ text: String, in textStorage: NSTextStorage, baseFont: NSFont) {
49 let fullRange = NSRange(location: 0, length: textStorage.length)
67 let fullRange = NSRange(location: 0, length: textStorage.length)
@@ -59,42 +77,39 @@ enum SQLSyntaxHighlighter {
59 var protectedRanges: [NSRange] = []
77 var protectedRanges: [NSRange] = []
60
78
61 // 1. Comments (-- to end of line)
79 // 1. Comments (-- to end of line)
62− applyPattern("--[^\n]*", to: textStorage, text: nsText,
80+ applyRegex(commentRegex, to: textStorage, text: nsText,
63− color: .secondaryLabelColor, protectedRanges: &protectedRanges)
81+ color: .secondaryLabelColor, protectedRanges: &protectedRanges)
64
82
65 // 2. Single-quoted strings
83 // 2. Single-quoted strings
66− applyPattern("'[^']*(?:''[^']*)*'", to: textStorage, text: nsText,
84+ applyRegex(singleQuoteRegex, to: textStorage, text: nsText,
67− color: .systemRed, protectedRanges: &protectedRanges)
85+ color: .systemRed, protectedRanges: &protectedRanges)
68
86
69 // 3. Double-quoted identifiers
87 // 3. Double-quoted identifiers
70− applyPattern("\"[^\"]*(?:\"\"[^\"]*)*\"", to: textStorage, text: nsText,
88+ applyRegex(doubleQuoteRegex, to: textStorage, text: nsText,
71− color: .systemRed, protectedRanges: &protectedRanges)
89+ color: .systemRed, protectedRanges: &protectedRanges)
72
90
73 // 4. Numbers (outside protected ranges)
91 // 4. Numbers (outside protected ranges)
74− applyUnprotectedPattern("\\b\\d+(?:\\.\\d+)?\\b", to: textStorage, text: nsText,
92+ applyUnprotectedRegex(numberRegex, to: textStorage, text: nsText,
75− color: .systemPurple, protectedRanges: protectedRanges)
93+ color: .systemPurple, protectedRanges: protectedRanges)
76
94
77 // 5. Keywords (outside protected ranges)
95 // 5. Keywords (outside protected ranges)
78− let keywordPattern = "\\b(?:" + keywords.sorted().joined(separator: "|") + ")\\b"
96+ applyUnprotectedKeywordRegex(keywordRegex, to: textStorage, text: nsText,
79− applyUnprotectedKeywordPattern(keywordPattern, to: textStorage, text: nsText,
80− color: .systemBlue, bold: true, baseFont: baseFont,
97+ color: .systemBlue, bold: true, baseFont: baseFont,
81− protectedRanges: protectedRanges)
98+ protectedRanges: protectedRanges)
82
99
83 // 6. Functions (outside protected ranges)
100 // 6. Functions (outside protected ranges)
84− let functionPattern = "\\b(?:" + functions.sorted().joined(separator: "|") + ")\\b"
101+ applyUnprotectedKeywordRegex(functionRegex, to: textStorage, text: nsText,
85− applyUnprotectedKeywordPattern(functionPattern, to: textStorage, text: nsText,
86− color: .systemTeal, bold: false, baseFont: baseFont,
102+ color: .systemTeal, bold: false, baseFont: baseFont,
87− protectedRanges: protectedRanges)
103+ protectedRanges: protectedRanges)
88 }
104 }
89
105
90− private static func applyPattern(
106+ private static func applyRegex(
91− _ pattern: String,
107+ _ regex: NSRegularExpression,
92 to textStorage: NSTextStorage,
108 to textStorage: NSTextStorage,
93 text: NSString,
109 text: NSString,
94 color: NSColor,
110 color: NSColor,
95 protectedRanges: inout [NSRange]
111 protectedRanges: inout [NSRange]
96 ) {
112 ) {
97− guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return }
98 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
113 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
99 for match in matches {
114 for match in matches {
100 textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
115 textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
@@ -102,22 +117,21 @@ enum SQLSyntaxHighlighter {
102 }
117 }
103 }
118 }
104
119
105− private static func applyUnprotectedPattern(
120+ private static func applyUnprotectedRegex(
106− _ pattern: String,
121+ _ regex: NSRegularExpression,
107 to textStorage: NSTextStorage,
122 to textStorage: NSTextStorage,
108 text: NSString,
123 text: NSString,
109 color: NSColor,
124 color: NSColor,
110 protectedRanges: [NSRange]
125 protectedRanges: [NSRange]
111 ) {
126 ) {
112− guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
113 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
127 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
114 for match in matches where !isProtected(match.range, by: protectedRanges) {
128 for match in matches where !isProtected(match.range, by: protectedRanges) {
115 textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
129 textStorage.addAttribute(.foregroundColor, value: color, range: match.range)
116 }
130 }
117 }
131 }
118
132
119− private static func applyUnprotectedKeywordPattern(
133+ private static func applyUnprotectedKeywordRegex(
120− _ pattern: String,
134+ _ regex: NSRegularExpression,
121 to textStorage: NSTextStorage,
135 to textStorage: NSTextStorage,
122 text: NSString,
136 text: NSString,
123 color: NSColor,
137 color: NSColor,
@@ -125,7 +139,6 @@ enum SQLSyntaxHighlighter {
125 baseFont: NSFont,
139 baseFont: NSFont,
126 protectedRanges: [NSRange]
140 protectedRanges: [NSRange]
127 ) {
141 ) {
128− guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return }
129 let boldFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .bold)
142 let boldFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .bold)
130 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
143 let matches = regex.matches(in: text as String, range: NSRange(location: 0, length: text.length))
131 for match in matches where !isProtected(match.range, by: protectedRanges) {
144 for match in matches where !isProtected(match.range, by: protectedRanges) {
@@ -151,6 +164,12 @@ enum SQLSyntaxHighlighter {
151
164
152enum JSONSyntaxHighlighter {
165enum JSONSyntaxHighlighter {
153
166
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+
154 /// Creates a syntax-highlighted AttributedString from a JSON string.
173 /// Creates a syntax-highlighted AttributedString from a JSON string.
155 static func highlight(_ json: String, font: NSFont? = nil) -> AttributedString {
174 static func highlight(_ json: String, font: NSFont? = nil) -> AttributedString {
156 let resolvedFont = font ?? NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
175 let resolvedFont = font ?? NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
@@ -168,45 +187,37 @@ enum JSONSyntaxHighlighter {
168 var stringRanges: [NSRange] = []
187 var stringRanges: [NSRange] = []
169
188
170 // 1. Double-quoted strings — distinguish keys from values
189 // 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)
190+ let matches = stringRegex.matches(in: json, range: fullRange)
173− for match in matches {
191+ for match in matches {
174− stringRanges.append(match.range)
192+ stringRanges.append(match.range)
175−
193+
176− let isKey = isJSONKey(after: match.range, in: nsString)
194+ let isKey = isJSONKey(after: match.range, in: nsString)
177− let color: NSColor = isKey ? .systemCyan : .systemRed
195+ let color: NSColor = isKey ? .systemCyan : .systemRed
178− attributed.addAttribute(.foregroundColor, value: color, range: match.range)
196+ attributed.addAttribute(.foregroundColor, value: color, range: match.range)
179− if isKey {
197+ if isKey {
180− attributed.addAttribute(
198+ attributed.addAttribute(
181− .font,
199+ .font,
182− value: NSFont.monospacedSystemFont(ofSize: resolvedFont.pointSize, weight: .medium),
200+ value: NSFont.monospacedSystemFont(ofSize: resolvedFont.pointSize, weight: .medium),
183− range: match.range)
201+ range: match.range)
184− }
185 }
202 }
186 }
203 }
187
204
188 // 2. Booleans (outside strings)
205 // 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)
206+ for match in boolRegex.matches(in: json, range: fullRange)
191− where !isInString(match.range, stringRanges: stringRanges) {
207+ where !isInString(match.range, stringRanges: stringRanges) {
192− attributed.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range)
208+ attributed.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: match.range)
193− }
194 }
209 }
195
210
196 // 3. Null (outside strings)
211 // 3. Null (outside strings)
197− if let regex = try? NSRegularExpression(pattern: "\\bnull\\b", options: []) {
198− for match in regex.matches(in: json, range: fullRange)
212+ for match in nullRegex.matches(in: json, range: fullRange)
199− where !isInString(match.range, stringRanges: stringRanges) {
213+ where !isInString(match.range, stringRanges: stringRanges) {
200− attributed.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: match.range)
214+ attributed.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: match.range)
201− }
202 }
215 }
203
216
204 // 4. Numbers (outside strings)
217 // 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)
218+ for match in numberRegex.matches(in: json, range: fullRange)
207− where !isInString(match.range, stringRanges: stringRanges) {
219+ where !isInString(match.range, stringRanges: stringRanges) {
208− attributed.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range)
220+ attributed.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: match.range)
209− }
210 }
221 }
211
222
212 return AttributedString(attributed)
223 return AttributedString(attributed)