git.iko.soy/parseable-macos
on master help sign in
Implement inline SQL error highlighting with red underline (#39)
commit329933791d4e189c2e7b4c09cdf07518c0ad14d7 authoriko <mail@iko.soy> dateMon 16 Feb 2026, 01:56 (8 months ago) parent5f1c7be
6 files changed+287−1
ParseableViewer.xcodeproj/project.pbxproj+27 ParseableViewer/Models/SQLTokenizer.swift+87 ParseableViewer/ViewModels/QueryViewModel.swift+11 ParseableViewer/Views/QueryView.swift+2−1 ParseableViewer/Views/SQLEditorView.swift+63 ParseableViewerTests/SQLTokenizerTests.swift+97
ParseableViewer.xcodeproj/project.pbxproj+27
@@ -16,6 +16,7 @@
1616 2C35EBC496AF8DAB215800F0 /* LogDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AA610FFA85948D794C5A4CC /* LogDetailView.swift */; };
1717 2E3E0E23D3388B90955AD9A8 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D36767781BD4F0650C1CD27 /* KeychainService.swift */; };
1818 308E024A853EDA08411FE6B0 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 187537810EBBCC784604017D /* UsersView.swift */; };
19+ 355EA224A0C07271A0F9EBDA /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFECFECB73C6B7DA1D0F99F /* AboutView.swift */; };
1920 3CDE9527F8C25B7C32571CB4 /* ConnectionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 996D7D107A1959CF67E82DF5 /* ConnectionStoreTests.swift */; };
2021 3F7EB642D3EBA91FCC962F15 /* ServerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7C589A8DC2293DDDE4D60F /* ServerInfo.swift */; };
2122 3FDB32277A679A14D1455A7D /* SQLAutoCompleteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B692AC5D403FB0CBB28935B /* SQLAutoCompleteTests.swift */; };
@@ -68,6 +69,7 @@
6869 187537810EBBCC784604017D /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = "<group>"; };
6970 18D05E439E6063E901A3E7EC /* ServerConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConnection.swift; sourceTree = "<group>"; };
7071 194F81AC44933EFE8C5F9824 /* LogStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogStream.swift; sourceTree = "<group>"; };
72+ 1FFECFECB73C6B7DA1D0F99F /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
7173 2535C31705B3ADC1C8628380 /* SyntaxHighlightingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntaxHighlightingTests.swift; sourceTree = "<group>"; };
7274 2B692AC5D403FB0CBB28935B /* SQLAutoCompleteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQLAutoCompleteTests.swift; sourceTree = "<group>"; };
7375 30A1771C7B952AEB93E35ADF /* ParseableClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParseableClient.swift; sourceTree = "<group>"; };
@@ -124,6 +126,7 @@
124126 171EC835252990F6B7BBB45D /* Views */ = {
125127 isa = PBXGroup;
126128 children = (
129+ 1FFECFECB73C6B7DA1D0F99F /* AboutView.swift */,
127130 878573090C3AB36A21A32DFA /* AlertsView.swift */,
128131 9DF1D667CE043D657046ABC1 /* ConnectionSheet.swift */,
129132 C3EB52E9BEDCAF82C6A155FA /* ContentView.swift */,
@@ -244,6 +247,7 @@
244247 buildPhases = (
245248 E7518B0D053D4667ECBB99E3 /* Sources */,
246249 27EFF17FAE6D34920BFD0BB2 /* Resources */,
250+ B271B96656D788CF64318006 /* Set Version from Git Commit Timestamp */,
247251 );
248252 buildRules = (
249253 );
@@ -319,11 +323,34 @@
319323 };
320324/* End PBXResourcesBuildPhase section */
321325
326+/* Begin PBXShellScriptBuildPhase section */
327+ B271B96656D788CF64318006 /* Set Version from Git Commit Timestamp */ = {
328+ isa = PBXShellScriptBuildPhase;
329+ alwaysOutOfDate = 1;
330+ buildActionMask = 2147483647;
331+ files = (
332+ );
333+ inputFileListPaths = (
334+ );
335+ inputPaths = (
336+ );
337+ name = "Set Version from Git Commit Timestamp";
338+ outputFileListPaths = (
339+ );
340+ outputPaths = (
341+ );
342+ runOnlyForDeploymentPostprocessing = 0;
343+ shellPath = /bin/sh;
344+ shellScript = "COMMIT_TIMESTAMP=$(TZ=UTC git log -1 --format='%cd' --date=format-local:'%Y%m%d.%H%M%S' 2>/dev/null)\nif [ -n \"$COMMIT_TIMESTAMP\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion ${COMMIT_TIMESTAMP}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n /usr/libexec/PlistBuddy -c \"Set :CFBundleShortVersionString ${COMMIT_TIMESTAMP}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nfi\n";
345+ };
346+/* End PBXShellScriptBuildPhase section */
347+
322348/* Begin PBXSourcesBuildPhase section */
323349 E7518B0D053D4667ECBB99E3 /* Sources */ = {
324350 isa = PBXSourcesBuildPhase;
325351 buildActionMask = 2147483647;
326352 files = (
353+ 355EA224A0C07271A0F9EBDA /* AboutView.swift in Sources */,
327354 B1BBABAB489838F84E670DD6 /* AlertModels.swift in Sources */,
328355 4A269FCA596D52F688BB055A /* AlertsView.swift in Sources */,
329356 15A0B57711D4B39118F09D93 /* AppState.swift in Sources */,
ParseableViewer/Models/SQLTokenizer.swift+87
@@ -1,5 +1,27 @@
11import Foundation
22
3+/// Parsed line/column position from a DataFusion error message.
4+struct SQLErrorPosition: Equatable, Sendable {
5+ let line: Int // 1-based
6+ let column: Int // 1-based
7+
8+ /// Parses a position from a DataFusion error string such as
9+ /// `"Expected: an expression, found: FROM at Line: 1, Column 15"`.
10+ static func parse(from message: String) -> SQLErrorPosition? {
11+ // DataFusion sometimes includes a colon after "Column" and sometimes doesn't
12+ let pattern = #"Line:\s*(\d+),\s*Column:?\s*(\d+)"#
13+ guard let regex = try? NSRegularExpression(pattern: pattern),
14+ let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
15+ let lineRange = Range(match.range(at: 1), in: message),
16+ let colRange = Range(match.range(at: 2), in: message),
17+ let line = Int(message[lineRange]),
18+ let col = Int(message[colRange]) else {
19+ return nil
20+ }
21+ return SQLErrorPosition(line: line, column: col)
22+ }
23+}
24+
325/// A minimal SQL tokenizer and parser for rewriting SELECT column lists.
426struct SQLTokenizer: Sendable {
527
@@ -244,4 +266,69 @@ struct SQLTokenizer: Sendable {
244266 advance(&i, in: sql, while: { $0.isNumber })
245267 }
246268 }
269+
270+ // MARK: - Error position helpers
271+
272+ /// Converts a 1-based line/column into a UTF-16 offset within `sql`.
273+ /// Returns `nil` if the position is out of bounds.
274+ static func characterOffset(line: Int, column: Int, in sql: String) -> Int? {
275+ guard line >= 1, column >= 1 else { return nil }
276+ let nsString = sql as NSString
277+ let length = nsString.length
278+
279+ // Walk to the start of the target line
280+ var currentLine = 1
281+ var i = 0
282+ while currentLine < line {
283+ guard i < length else { return nil }
284+ if nsString.character(at: i) == 0x0A { // \n
285+ currentLine += 1
286+ }
287+ i += 1
288+ }
289+
290+ let offset = i + (column - 1)
291+ guard offset <= length else { return nil }
292+ return offset
293+ }
294+
295+ /// Returns the `NSRange` of the token at the given UTF-16 offset.
296+ /// If the offset falls on whitespace/trivia, snaps to the next non-trivia token.
297+ static func tokenRange(atOffset offset: Int, in sql: String) -> NSRange? {
298+ let tokens = tokenize(sql)
299+ let nsString = sql as NSString
300+
301+ // Convert token Swift ranges to NSRanges for comparison
302+ for (idx, token) in tokens.enumerated() {
303+ let nsRange = NSRange(token.range, in: sql)
304+ if offset >= nsRange.location && offset < nsRange.location + nsRange.length {
305+ if token.kind.isTrivia {
306+ // Snap to the next non-trivia token
307+ for next in tokens[(idx + 1)...] {
308+ if !next.kind.isTrivia {
309+ return NSRange(next.range, in: sql)
310+ }
311+ }
312+ return nil
313+ }
314+ return nsRange
315+ }
316+ }
317+
318+ // Offset is at or past end — return last non-trivia token
319+ if offset >= nsString.length {
320+ for token in tokens.reversed() {
321+ if !token.kind.isTrivia {
322+ return NSRange(token.range, in: sql)
323+ }
324+ }
325+ }
326+ return nil
327+ }
328+
329+ /// Convenience: parses a line/column into an `NSRange` for the enclosing token.
330+ static func errorHighlightRange(line: Int, column: Int, in sql: String) -> NSRange? {
331+ guard let offset = characterOffset(line: line, column: column, in: sql) else { return nil }
332+ return tokenRange(atOffset: offset, in: sql)
333+ }
247334}
ParseableViewer/ViewModels/QueryViewModel.swift+11
@@ -16,6 +16,7 @@ final class QueryViewModel {
1616 private var currentStream: String?
1717 var isLoading = false
1818 var errorMessage: String?
19+ var errorRange: NSRange?
1920 var resultCount = 0
2021 var queryDuration: TimeInterval?
2122 var selectedLogEntry: LogRecord?
@@ -301,6 +302,7 @@ final class QueryViewModel {
301302
302303 isLoading = true
303304 errorMessage = nil
305+ errorRange = nil
304306 resultsTruncated = false
305307 let startTime = CFAbsoluteTimeGetCurrent()
306308
@@ -344,6 +346,13 @@ final class QueryViewModel {
344346 columns = []
345347 } catch {
346348 errorMessage = ParseableError.userFriendlyMessage(for: error)
349+ if let serverError = error as? ParseableError,
350+ case .serverError(_, let msg) = serverError,
351+ let pos = SQLErrorPosition.parse(from: msg) {
352+ errorRange = SQLTokenizer.errorHighlightRange(
353+ line: pos.line, column: pos.column, in: sqlQuery
354+ )
355+ }
347356 results = []
348357 columns = []
349358 }
@@ -360,6 +369,7 @@ final class QueryViewModel {
360369 queryTask = nil
361370 isLoading = false
362371 errorMessage = "Query cancelled"
372+ errorRange = nil
363373 }
364374
365375 @MainActor
@@ -466,6 +476,7 @@ final class QueryViewModel {
466476 queryDuration = nil
467477 selectedLogEntry = nil
468478 errorMessage = nil
479+ errorRange = nil
469480 resultsTruncated = false
470481 schemaFields = []
471482 }
ParseableViewer/Views/QueryView.swift+2−1
@@ -121,7 +121,8 @@ struct QueryView: View {
121121 SQLEditorView(
122122 text: $vm.sqlQuery,
123123 streamNames: appState.streams.map(\.name),
124− schemaFields: viewModel.schemaFields
124+ schemaFields: viewModel.schemaFields,
125+ errorRange: viewModel.errorRange
125126 )
126127 .frame(minHeight: 60, maxHeight: 120)
127128 .clipShape(RoundedRectangle(cornerRadius: 4))
ParseableViewer/Views/SQLEditorView.swift+63
@@ -6,6 +6,7 @@ struct SQLEditorView: NSViewRepresentable {
66 @Binding var text: String
77 var streamNames: [String] = []
88 var schemaFields: [SchemaField] = []
9+ var errorRange: NSRange?
910
1011 static let editorFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
1112
@@ -38,6 +39,7 @@ struct SQLEditorView: NSViewRepresentable {
3839
3940 textView.string = text
4041 context.coordinator.applyHighlighting(to: textView)
42+ context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
4143
4244 scrollView.hasVerticalScroller = true
4345 scrollView.hasHorizontalScroller = false
@@ -62,6 +64,7 @@ struct SQLEditorView: NSViewRepresentable {
6264 textView.selectedRanges = validRanges
6365 }
6466 }
67+ context.coordinator.updateErrorHighlight(in: textView, errorRange: errorRange)
6568 }
6669
6770 func makeCoordinator() -> Coordinator {
@@ -74,6 +77,11 @@ struct SQLEditorView: NSViewRepresentable {
7477 var streamNames: [String]
7578 var schemaFields: [SchemaField]
7679
80+ /// Tracks the currently applied error underline so we can clear it.
81+ private var lastAppliedErrorRange: NSRange?
82+ /// The text content when the error was applied; used to detect stale ranges.
83+ private var errorAppliedForText: String?
84+
7785 private let completionPopup = SQLCompletionPopup()
7886 /// Tracks whether we are inserting a completion so that `textDidChange`
7987 /// doesn't re-trigger the popup for the replacement.
@@ -95,6 +103,7 @@ struct SQLEditorView: NSViewRepresentable {
95103 func textDidChange(_ notification: Notification) {
96104 guard let textView = notification.object as? NSTextView else { return }
97105 isEditing = true
106+ clearErrorHighlight(in: textView)
98107 text.wrappedValue = textView.string
99108 applyHighlighting(to: textView)
100109 isEditing = false
@@ -209,6 +218,60 @@ struct SQLEditorView: NSViewRepresentable {
209218 }
210219 }
211220
221+ // MARK: - Error Highlighting
222+
223+ func updateErrorHighlight(in textView: NSTextView, errorRange: NSRange?) {
224+ guard let layoutManager = textView.layoutManager else { return }
225+ let text = textView.string
226+
227+ // If text changed since we last applied an error, clear stale highlight
228+ if let appliedText = errorAppliedForText, appliedText != text {
229+ clearErrorHighlight(in: textView)
230+ }
231+
232+ guard let range = errorRange else {
233+ if lastAppliedErrorRange != nil {
234+ clearErrorHighlight(in: textView)
235+ }
236+ return
237+ }
238+
239+ // Validate range is within bounds
240+ let fullLength = (text as NSString).length
241+ guard range.location + range.length <= fullLength else { return }
242+
243+ // Don't re-apply the same highlight
244+ if lastAppliedErrorRange == range && errorAppliedForText == text { return }
245+
246+ // Clear any previous highlight first
247+ if lastAppliedErrorRange != nil {
248+ clearErrorHighlight(in: textView)
249+ }
250+
251+ layoutManager.addTemporaryAttribute(
252+ .underlineStyle,
253+ value: NSUnderlineStyle.thick.rawValue,
254+ forCharacterRange: range
255+ )
256+ layoutManager.addTemporaryAttribute(
257+ .underlineColor,
258+ value: NSColor.systemRed,
259+ forCharacterRange: range
260+ )
261+
262+ lastAppliedErrorRange = range
263+ errorAppliedForText = text
264+ }
265+
266+ func clearErrorHighlight(in textView: NSTextView) {
267+ guard let layoutManager = textView.layoutManager else { return }
268+ let fullRange = NSRange(location: 0, length: (textView.string as NSString).length)
269+ layoutManager.removeTemporaryAttribute(.underlineStyle, forCharacterRange: fullRange)
270+ layoutManager.removeTemporaryAttribute(.underlineColor, forCharacterRange: fullRange)
271+ lastAppliedErrorRange = nil
272+ errorAppliedForText = nil
273+ }
274+
212275 // MARK: - Helpers
213276
214277 func applyHighlighting(to textView: NSTextView) {
ParseableViewerTests/SQLTokenizerTests.swift+97
@@ -251,4 +251,101 @@ final class SQLTokenizerTests: XCTestCase {
251251 XCTAssertNotNil(range)
252252 XCTAssertEqual(String(sql[range!]), #""from""#)
253253 }
254+
255+ // MARK: - SQLErrorPosition.parse
256+
257+ func testParseDataFusionError() {
258+ let msg = "Expected: an expression, found: FROM at Line: 1, Column 15"
259+ let pos = SQLErrorPosition.parse(from: msg)
260+ XCTAssertEqual(pos, SQLErrorPosition(line: 1, column: 15))
261+ }
262+
263+ func testParseDataFusionErrorWithColonAfterColumn() {
264+ let msg = "Error at Line: 2, Column: 10"
265+ let pos = SQLErrorPosition.parse(from: msg)
266+ XCTAssertEqual(pos, SQLErrorPosition(line: 2, column: 10))
267+ }
268+
269+ func testParseReturnsNilForNoPosition() {
270+ let msg = "Some generic error without position info"
271+ XCTAssertNil(SQLErrorPosition.parse(from: msg))
272+ }
273+
274+ func testParseReturnsNilForEmptyString() {
275+ XCTAssertNil(SQLErrorPosition.parse(from: ""))
276+ }
277+
278+ // MARK: - characterOffset
279+
280+ func testCharacterOffsetSingleLine() {
281+ let sql = "SELECT * FROM t"
282+ // Line 1, Column 8 → offset 7 (the '*')
283+ XCTAssertEqual(SQLTokenizer.characterOffset(line: 1, column: 8, in: sql), 7)
284+ }
285+
286+ func testCharacterOffsetMultiLine() {
287+ let sql = "SELECT *\nFROM t"
288+ // Line 2, Column 1 → offset 9 (the 'F' of FROM)
289+ XCTAssertEqual(SQLTokenizer.characterOffset(line: 2, column: 1, in: sql), 9)
290+ }
291+
292+ func testCharacterOffsetLineBeyondEnd() {
293+ let sql = "SELECT *"
294+ XCTAssertNil(SQLTokenizer.characterOffset(line: 3, column: 1, in: sql))
295+ }
296+
297+ func testCharacterOffsetColumnBeyondEnd() {
298+ let sql = "SELECT *"
299+ // Column 100 on a short string
300+ XCTAssertNil(SQLTokenizer.characterOffset(line: 1, column: 100, in: sql))
301+ }
302+
303+ // MARK: - tokenRange
304+
305+ func testTokenRangeAtKeyword() {
306+ let sql = "SELEC * FROM t"
307+ // Offset 0 is at the start of "SELEC" (which is an identifier, not a keyword)
308+ let range = SQLTokenizer.tokenRange(atOffset: 0, in: sql)
309+ XCTAssertNotNil(range)
310+ XCTAssertEqual((sql as NSString).substring(with: range!), "SELEC")
311+ }
312+
313+ func testTokenRangeAtWhitespaceSnapsToNextToken() {
314+ let sql = "SELECT * FROM t"
315+ // Offset 6 is inside the whitespace between SELECT and *
316+ let range = SQLTokenizer.tokenRange(atOffset: 7, in: sql)
317+ XCTAssertNotNil(range)
318+ XCTAssertEqual((sql as NSString).substring(with: range!), "*")
319+ }
320+
321+ func testTokenRangeAtIdentifier() {
322+ let sql = "SELECT * FROM mytable"
323+ // Offset 14 is at 'm' of "mytable"
324+ let range = SQLTokenizer.tokenRange(atOffset: 14, in: sql)
325+ XCTAssertNotNil(range)
326+ XCTAssertEqual((sql as NSString).substring(with: range!), "mytable")
327+ }
328+
329+ // MARK: - errorHighlightRange (end-to-end)
330+
331+ func testErrorHighlightRangeTypoInKeyword() {
332+ let sql = "SELEC * FROM t"
333+ // DataFusion would report Line: 1, Column 1 for SELEC
334+ let range = SQLTokenizer.errorHighlightRange(line: 1, column: 1, in: sql)
335+ XCTAssertNotNil(range)
336+ XCTAssertEqual((sql as NSString).substring(with: range!), "SELEC")
337+ }
338+
339+ func testErrorHighlightRangeMultiLine() {
340+ let sql = "SELECT *\nFRO t"
341+ // Line 2, Column 1 → "FRO"
342+ let range = SQLTokenizer.errorHighlightRange(line: 2, column: 1, in: sql)
343+ XCTAssertNotNil(range)
344+ XCTAssertEqual((sql as NSString).substring(with: range!), "FRO")
345+ }
346+
347+ func testErrorHighlightRangeInvalidPosition() {
348+ let sql = "SELECT * FROM t"
349+ XCTAssertNil(SQLTokenizer.errorHighlightRange(line: 99, column: 1, in: sql))
350+ }
254351}