git.iko.soy/parseable-macos
on master help sign in
Add context-aware SQL autocomplete to the query editor (#13)

The SQL editor now shows a floating completion popup as the user types, with suggestions drawn from SQL keywords, built-in functions, stream (table) names, and schema column names. Context detection determines what kind of completions to offer: after FROM/JOIN it suggests table names, after SELECT/WHERE/AND it suggests columns and functions, and after ORDER/GROUP it suggests BY. The popup supports keyboard navigation (arrow keys), Tab/Enter to accept, and Escape to dismiss. Schema fields are loaded from the Parseable API when a stream is selected, making column suggestions available immediately. https://claude.ai/code/session_01TDeKsxGgSXp9nvXEXuh5VC Co-authored-by: Claude <noreply@anthropic.com>

commit08d5ed3840c216040f931a548cc483005abff6fb authoriko <mail@iko.soy> dateSun 15 Feb 2026, 13:38 (8 months ago) parentd7cb626
5 files changed+896−4
ParseableViewer/ViewModels/QueryViewModel.swift+19 ParseableViewer/Views/QueryView.swift+13−1 ParseableViewer/Views/SQLAutoComplete.swift+422 ParseableViewer/Views/SQLEditorView.swift+161−3 ParseableViewerTests/SQLAutoCompleteTests.swift+281
ParseableViewer/ViewModels/QueryViewModel.swift+19
@@ -21,6 +21,9 @@ final class QueryViewModel {
2121 }
2222 var resultsTruncated = false
2323
24+ // Schema fields for autocomplete
25+ var schemaFields: [SchemaField] = []
26+
2427 // Query task for cancellation
2528 private var queryTask: Task<Void, Never>?
2629
@@ -304,6 +307,21 @@ final class QueryViewModel {
304307 errorMessage = "Query cancelled"
305308 }
306309
310+ @MainActor
311+ func loadSchema(client: ParseableClient?, stream: String?) async {
312+ guard let client, let stream else {
313+ schemaFields = []
314+ return
315+ }
316+ do {
317+ let schema = try await client.getStreamSchema(stream: stream)
318+ schemaFields = schema.fields
319+ } catch {
320+ // Schema load is best-effort; don't surface errors
321+ schemaFields = []
322+ }
323+ }
324+
307325 /// Single-pass column extraction with priority ordering.
308326 func extractColumns(from records: [LogRecord]) -> [String] {
309327 let priorityFields = ["p_timestamp", "p_tags", "p_metadata", "level", "severity", "message", "msg"]
@@ -378,6 +396,7 @@ final class QueryViewModel {
378396 selectedLogEntry = nil
379397 errorMessage = nil
380398 resultsTruncated = false
399+ schemaFields = []
381400 }
382401
383402 /// Modifies `sqlQuery` to add a column-value filter condition.
ParseableViewer/Views/QueryView.swift+13−1
@@ -107,7 +107,11 @@ struct QueryView: View {
107107 .padding(8)
108108
109109 // SQL editor
110− SQLEditorView(text: $vm.sqlQuery)
110+ SQLEditorView(
111+ text: $vm.sqlQuery,
112+ streamNames: appState.streams.map(\.name),
113+ schemaFields: viewModel.schemaFields
114+ )
111115 .frame(minHeight: 60, maxHeight: 120)
112116 .clipShape(RoundedRectangle(cornerRadius: 4))
113117 .overlay(
@@ -229,6 +233,9 @@ struct QueryView: View {
229233 viewModel.clearResults()
230234 if let stream = newValue {
231235 let didSetDefault = viewModel.setDefaultQuery(stream: stream, previousStream: oldValue)
236+ Task {
237+ await viewModel.loadSchema(client: appState.client, stream: stream)
238+ }
232239 if didSetDefault {
233240 Task {
234241 await viewModel.executeQuery(
@@ -241,6 +248,11 @@ struct QueryView: View {
241248 }
242249 .onAppear {
243250 if let stream = appState.selectedStream {
251+ if viewModel.schemaFields.isEmpty {
252+ Task {
253+ await viewModel.loadSchema(client: appState.client, stream: stream)
254+ }
255+ }
244256 let didSetDefault = viewModel.setDefaultQuery(stream: stream)
245257 if didSetDefault && viewModel.results.isEmpty {
246258 Task {
ParseableViewer/Views/SQLAutoComplete.swiftnew file+422
@@ -0,0 +1,422 @@
1+import AppKit
2+
3+// MARK: - Completion Item
4+
5+struct SQLCompletionItem: Hashable {
6+ enum Kind: String {
7+ case keyword
8+ case function
9+ case table
10+ case column
11+ }
12+
13+ let text: String
14+ let kind: Kind
15+ let detail: String?
16+ /// The raw text to insert (without quoting). For tables this equals the
17+ /// unquoted stream name so prefix matching works correctly.
18+ let insertText: String
19+
20+ init(text: String, kind: Kind, detail: String? = nil, insertText: String? = nil) {
21+ self.text = text
22+ self.kind = kind
23+ self.detail = detail
24+ self.insertText = insertText ?? text
25+ }
26+
27+ var kindLabel: String {
28+ switch kind {
29+ case .keyword: return "K"
30+ case .function: return "F"
31+ case .table: return "T"
32+ case .column: return "C"
33+ }
34+ }
35+
36+ var kindColor: NSColor {
37+ switch kind {
38+ case .keyword: return .systemBlue
39+ case .function: return .systemTeal
40+ case .table: return .systemGreen
41+ case .column: return .systemOrange
42+ }
43+ }
44+}
45+
46+// MARK: - Completion Context
47+
48+enum SQLCompletionContext {
49+ case general
50+ case tableRef
51+ case columnRef
52+ case afterOrder
53+ case afterGroup
54+}
55+
56+// MARK: - Completion Provider
57+
58+enum SQLCompletionProvider {
59+
60+ /// Returns matching completion items, the typed prefix, and the NSRange of
61+ /// that prefix in the source text.
62+ static func completions(
63+ for text: String,
64+ cursorPosition: Int,
65+ streamNames: [String],
66+ schemaFields: [SchemaField]
67+ ) -> (items: [SQLCompletionItem], prefix: String, range: NSRange) {
68+ let nsText = text as NSString
69+ guard cursorPosition > 0, cursorPosition <= nsText.length else {
70+ return ([], "", NSRange(location: 0, length: 0))
71+ }
72+
73+ // Find the start of the current word (letters, digits, underscore)
74+ var wordStart = cursorPosition
75+ while wordStart > 0 {
76+ let ch = nsText.character(at: wordStart - 1)
77+ if isWordCharacter(ch) {
78+ wordStart -= 1
79+ } else {
80+ break
81+ }
82+ }
83+
84+ let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
85+ let range = NSRange(location: wordStart, length: cursorPosition - wordStart)
86+
87+ guard !prefix.isEmpty else {
88+ return ([], "", range)
89+ }
90+
91+ let context = determineContext(text: text, position: wordStart)
92+ let uppercasePrefix = prefix.uppercased()
93+
94+ var items: [SQLCompletionItem] = []
95+
96+ switch context {
97+ case .tableRef:
98+ for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
99+ items.append(SQLCompletionItem(
100+ text: "\"\(name)\"",
101+ kind: .table,
102+ insertText: name
103+ ))
104+ }
105+
106+ case .columnRef:
107+ for field in schemaFields.sorted(by: { $0.name < $1.name })
108+ where field.name.uppercased().hasPrefix(uppercasePrefix) {
109+ items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
110+ }
111+ for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
112+ items.append(SQLCompletionItem(text: fn, kind: .function))
113+ }
114+ for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
115+ items.append(SQLCompletionItem(text: kw, kind: .keyword))
116+ }
117+
118+ case .afterOrder, .afterGroup:
119+ if "BY".hasPrefix(uppercasePrefix) {
120+ items.append(SQLCompletionItem(text: "BY", kind: .keyword))
121+ }
122+
123+ case .general:
124+ for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
125+ items.append(SQLCompletionItem(text: kw, kind: .keyword))
126+ }
127+ for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
128+ items.append(SQLCompletionItem(text: fn, kind: .function))
129+ }
130+ for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
131+ items.append(SQLCompletionItem(
132+ text: "\"\(name)\"",
133+ kind: .table,
134+ insertText: name
135+ ))
136+ }
137+ for field in schemaFields.sorted(by: { $0.name < $1.name })
138+ where field.name.uppercased().hasPrefix(uppercasePrefix) {
139+ items.append(SQLCompletionItem(text: field.name, kind: .column, detail: field.dataType))
140+ }
141+ }
142+
143+ // Don't show the popup when the only match is an exact hit
144+ if items.count == 1 && items[0].text.uppercased() == uppercasePrefix {
145+ return ([], prefix, range)
146+ }
147+
148+ return (items, prefix, range)
149+ }
150+
151+ // MARK: - Context Detection
152+
153+ static func determineContext(text: String, position: Int) -> SQLCompletionContext {
154+ let before = String(text.prefix(position))
155+ let tokens = tokenize(before)
156+
157+ guard let lastToken = tokens.last?.uppercased() else {
158+ return .general
159+ }
160+
161+ switch lastToken {
162+ case "FROM", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "INTO":
163+ return .tableRef
164+ case "SELECT", "WHERE", "AND", "OR", "ON", "HAVING", "SET", "BY",
165+ "WHEN", "THEN", "ELSE", "CASE", "DISTINCT", "NOT", "BETWEEN", "LIKE", "IN", "IS":
166+ return .columnRef
167+ case "ORDER":
168+ return .afterOrder
169+ case "GROUP":
170+ return .afterGroup
171+ default:
172+ if lastToken == "," {
173+ for token in tokens.reversed() {
174+ let upper = token.uppercased()
175+ if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
176+ return .columnRef
177+ }
178+ if ["FROM", "JOIN"].contains(upper) {
179+ return .tableRef
180+ }
181+ }
182+ return .columnRef
183+ }
184+ return .general
185+ }
186+ }
187+
188+ // MARK: - Simple Tokenizer
189+
190+ private static func tokenize(_ text: String) -> [String] {
191+ var tokens: [String] = []
192+ var current = ""
193+ var inSingleQuote = false
194+ var inDoubleQuote = false
195+
196+ for ch in text {
197+ if ch == "'" && !inDoubleQuote {
198+ inSingleQuote.toggle()
199+ current.append(ch)
200+ } else if ch == "\"" && !inSingleQuote {
201+ inDoubleQuote.toggle()
202+ current.append(ch)
203+ } else if inSingleQuote || inDoubleQuote {
204+ current.append(ch)
205+ } else if ch == "," {
206+ let trimmed = current.trimmingCharacters(in: .whitespaces)
207+ if !trimmed.isEmpty { tokens.append(trimmed) }
208+ tokens.append(",")
209+ current = ""
210+ } else if ch == "(" || ch == ")" {
211+ let trimmed = current.trimmingCharacters(in: .whitespaces)
212+ if !trimmed.isEmpty { tokens.append(trimmed) }
213+ tokens.append(String(ch))
214+ current = ""
215+ } else if ch.isWhitespace {
216+ let trimmed = current.trimmingCharacters(in: .whitespaces)
217+ if !trimmed.isEmpty { tokens.append(trimmed) }
218+ current = ""
219+ } else {
220+ current.append(ch)
221+ }
222+ }
223+ // Do not include the in-progress word — it is the prefix we're completing
224+ return tokens
225+ }
226+
227+ static func isWordCharacter(_ ch: unichar) -> Bool {
228+ (ch >= 0x41 && ch <= 0x5A) || // A-Z
229+ (ch >= 0x61 && ch <= 0x7A) || // a-z
230+ (ch >= 0x30 && ch <= 0x39) || // 0-9
231+ ch == 0x5F // _
232+ }
233+}
234+
235+// MARK: - Completion Popup (child window with table view)
236+
237+final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDelegate {
238+
239+ private let panel: NSPanel
240+ private let tableView: NSTableView
241+ private let scrollView: NSScrollView
242+ private static let rowHeight: CGFloat = 22
243+ private static let maxVisibleRows = 8
244+ private static let panelWidth: CGFloat = 320
245+
246+ private(set) var items: [SQLCompletionItem] = []
247+ var selectedIndex: Int = 0
248+ var onAccept: ((SQLCompletionItem) -> Void)?
249+
250+ override init() {
251+ panel = NSPanel(
252+ contentRect: .zero,
253+ styleMask: [.borderless, .nonactivatingPanel],
254+ backing: .buffered,
255+ defer: true
256+ )
257+ panel.isFloatingPanel = true
258+ panel.hidesOnDeactivate = true
259+ panel.hasShadow = true
260+ panel.backgroundColor = .clear
261+ panel.isOpaque = false
262+ panel.level = .popUpMenu
263+
264+ // Table view
265+ tableView = NSTableView()
266+ tableView.headerView = nil
267+ tableView.rowHeight = Self.rowHeight
268+ tableView.intercellSpacing = NSSize(width: 4, height: 0)
269+ tableView.selectionHighlightStyle = .regular
270+ tableView.allowsEmptySelection = false
271+ tableView.gridStyleMask = []
272+
273+ let kindCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("kind"))
274+ kindCol.width = 22
275+ kindCol.minWidth = 22
276+ kindCol.maxWidth = 22
277+ tableView.addTableColumn(kindCol)
278+
279+ let textCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("text"))
280+ textCol.width = Self.panelWidth - 30
281+ tableView.addTableColumn(textCol)
282+
283+ // Scroll view
284+ scrollView = NSScrollView()
285+ scrollView.documentView = tableView
286+ scrollView.hasVerticalScroller = true
287+ scrollView.hasHorizontalScroller = false
288+ scrollView.autohidesScrollers = true
289+ scrollView.drawsBackground = true
290+ scrollView.backgroundColor = .windowBackgroundColor
291+ scrollView.wantsLayer = true
292+ scrollView.layer?.cornerRadius = 6
293+ scrollView.layer?.borderColor = NSColor.separatorColor.cgColor
294+ scrollView.layer?.borderWidth = 0.5
295+
296+ panel.contentView = scrollView
297+
298+ super.init()
299+
300+ tableView.dataSource = self
301+ tableView.delegate = self
302+ tableView.target = self
303+ tableView.doubleAction = #selector(doubleClicked)
304+ }
305+
306+ // MARK: - Public API
307+
308+ var isVisible: Bool { panel.isVisible }
309+
310+ func update(items: [SQLCompletionItem]) {
311+ self.items = items
312+ selectedIndex = 0
313+ tableView.reloadData()
314+
315+ if items.isEmpty {
316+ hide()
317+ return
318+ }
319+
320+ if !items.isEmpty {
321+ tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
322+ tableView.scrollRowToVisible(0)
323+ }
324+
325+ let rowCount = min(items.count, Self.maxVisibleRows)
326+ let height = CGFloat(rowCount) * Self.rowHeight + 4
327+ let frame = NSRect(x: panel.frame.origin.x, y: panel.frame.origin.y,
328+ width: Self.panelWidth, height: height)
329+ panel.setFrame(frame, display: true)
330+ }
331+
332+ func show(at screenPoint: NSPoint, in parentWindow: NSWindow) {
333+ let rowCount = min(items.count, Self.maxVisibleRows)
334+ let height = CGFloat(rowCount) * Self.rowHeight + 4
335+ let frame = NSRect(x: screenPoint.x, y: screenPoint.y - height,
336+ width: Self.panelWidth, height: height)
337+ panel.setFrame(frame, display: true)
338+ parentWindow.addChildWindow(panel, ordered: .above)
339+ panel.orderFront(nil)
340+ }
341+
342+ func hide() {
343+ panel.parent?.removeChildWindow(panel)
344+ panel.orderOut(nil)
345+ }
346+
347+ func moveSelectionUp() {
348+ guard !items.isEmpty else { return }
349+ selectedIndex = max(0, selectedIndex - 1)
350+ tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false)
351+ tableView.scrollRowToVisible(selectedIndex)
352+ }
353+
354+ func moveSelectionDown() {
355+ guard !items.isEmpty else { return }
356+ selectedIndex = min(items.count - 1, selectedIndex + 1)
357+ tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false)
358+ tableView.scrollRowToVisible(selectedIndex)
359+ }
360+
361+ func acceptSelection() {
362+ guard selectedIndex >= 0, selectedIndex < items.count else { return }
363+ onAccept?(items[selectedIndex])
364+ hide()
365+ }
366+
367+ // MARK: - NSTableViewDataSource
368+
369+ func numberOfRows(in tableView: NSTableView) -> Int {
370+ items.count
371+ }
372+
373+ // MARK: - NSTableViewDelegate
374+
375+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
376+ guard row < items.count else { return nil }
377+ let item = items[row]
378+
379+ if tableColumn?.identifier.rawValue == "kind" {
380+ let id = NSUserInterfaceItemIdentifier("KindCell")
381+ let cell: NSTextField
382+ if let reused = tableView.makeView(withIdentifier: id, owner: nil) as? NSTextField {
383+ cell = reused
384+ } else {
385+ cell = NSTextField(labelWithString: "")
386+ cell.identifier = id
387+ cell.alignment = .center
388+ cell.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
389+ }
390+ cell.stringValue = item.kindLabel
391+ cell.textColor = item.kindColor
392+ return cell
393+ }
394+
395+ let id = NSUserInterfaceItemIdentifier("TextCell")
396+ let cell: NSTextField
397+ if let reused = tableView.makeView(withIdentifier: id, owner: nil) as? NSTextField {
398+ cell = reused
399+ } else {
400+ cell = NSTextField(labelWithString: "")
401+ cell.identifier = id
402+ cell.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
403+ cell.lineBreakMode = .byTruncatingTail
404+ cell.maximumNumberOfLines = 1
405+ }
406+
407+ if let detail = item.detail {
408+ cell.stringValue = "\(item.text) \(detail)"
409+ } else {
410+ cell.stringValue = item.text
411+ }
412+ cell.textColor = .labelColor
413+ return cell
414+ }
415+
416+ @objc private func doubleClicked() {
417+ let row = tableView.clickedRow
418+ guard row >= 0, row < items.count else { return }
419+ selectedIndex = row
420+ acceptSelection()
421+ }
422+}
ParseableViewer/Views/SQLEditorView.swift+161−3
@@ -1,9 +1,11 @@
11import AppKit
22import SwiftUI
33
4−/// A syntax-highlighted SQL editor backed by NSTextView.
4+/// A syntax-highlighted SQL editor backed by NSTextView with autocomplete support.
55struct SQLEditorView: NSViewRepresentable {
66 @Binding var text: String
7+ var streamNames: [String] = []
8+ var schemaFields: [SchemaField] = []
79
810 static let editorFont = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
911
@@ -47,6 +49,9 @@ struct SQLEditorView: NSViewRepresentable {
4749 func updateNSView(_ nsView: NSScrollView, context: Context) {
4850 guard let textView = nsView.documentView as? NSTextView else { return }
4951
52+ context.coordinator.streamNames = streamNames
53+ context.coordinator.schemaFields = schemaFields
54+
5055 // Only update when text changed externally (not from user typing)
5156 if textView.string != text && !context.coordinator.isEditing {
5257 let selectedRanges = textView.selectedRanges
@@ -60,30 +65,183 @@ struct SQLEditorView: NSViewRepresentable {
6065 }
6166
6267 func makeCoordinator() -> Coordinator {
63− Coordinator(text: $text)
68+ Coordinator(text: $text, streamNames: streamNames, schemaFields: schemaFields)
6469 }
6570
6671 final class Coordinator: NSObject, NSTextViewDelegate {
6772 var text: Binding<String>
6873 var isEditing = false
74+ var streamNames: [String]
75+ var schemaFields: [SchemaField]
76+
77+ private let completionPopup = SQLCompletionPopup()
78+ /// Tracks whether we are inserting a completion so that `textDidChange`
79+ /// doesn't re-trigger the popup for the replacement.
80+ private var isInsertingCompletion = false
6981
70− init(text: Binding<String>) {
82+ init(text: Binding<String>, streamNames: [String], schemaFields: [SchemaField]) {
7183 self.text = text
84+ self.streamNames = streamNames
85+ self.schemaFields = schemaFields
86+ super.init()
87+
88+ completionPopup.onAccept = { [weak self] item in
89+ self?.insertCompletion(item)
90+ }
7291 }
7392
93+ // MARK: - NSTextViewDelegate
94+
7495 func textDidChange(_ notification: Notification) {
7596 guard let textView = notification.object as? NSTextView else { return }
7697 isEditing = true
7798 text.wrappedValue = textView.string
7899 applyHighlighting(to: textView)
79100 isEditing = false
101+
102+ if !isInsertingCompletion {
103+ updateCompletions(for: textView)
104+ }
105+ }
106+
107+ func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
108+ guard completionPopup.isVisible else { return false }
109+
110+ switch commandSelector {
111+ case #selector(NSResponder.moveDown(_:)):
112+ completionPopup.moveSelectionDown()
113+ return true
114+ case #selector(NSResponder.moveUp(_:)):
115+ completionPopup.moveSelectionUp()
116+ return true
117+ case #selector(NSResponder.insertTab(_:)):
118+ completionPopup.acceptSelection()
119+ return true
120+ case #selector(NSResponder.insertNewline(_:)):
121+ completionPopup.acceptSelection()
122+ return true
123+ case #selector(NSResponder.cancelOperation(_:)):
124+ completionPopup.hide()
125+ return true
126+ default:
127+ return false
128+ }
80129 }
81130
131+ // MARK: - Completions
132+
133+ private func updateCompletions(for textView: NSTextView) {
134+ let cursorPosition = textView.selectedRange().location
135+ let result = SQLCompletionProvider.completions(
136+ for: textView.string,
137+ cursorPosition: cursorPosition,
138+ streamNames: streamNames,
139+ schemaFields: schemaFields
140+ )
141+
142+ guard !result.items.isEmpty else {
143+ completionPopup.hide()
144+ return
145+ }
146+
147+ completionPopup.update(items: result.items)
148+
149+ if !completionPopup.isVisible, let window = textView.window {
150+ let screenPoint = cursorScreenPoint(for: textView)
151+ completionPopup.show(at: screenPoint, in: window)
152+ }
153+ }
154+
155+ private func insertCompletion(_ item: SQLCompletionItem) {
156+ // Find the text view — walk up from the popup's parent window
157+ guard let textView = findTextView() else { return }
158+
159+ let cursorPosition = textView.selectedRange().location
160+ let nsText = textView.string as NSString
161+
162+ // Find the prefix range to replace
163+ var wordStart = cursorPosition
164+ while wordStart > 0 {
165+ let ch = nsText.character(at: wordStart - 1)
166+ if SQLCompletionProvider.isWordCharacter(ch) {
167+ wordStart -= 1
168+ } else {
169+ break
170+ }
171+ }
172+
173+ let replaceRange = NSRange(location: wordStart, length: cursorPosition - wordStart)
174+
175+ isInsertingCompletion = true
176+ defer { isInsertingCompletion = false }
177+
178+ if textView.shouldChangeText(in: replaceRange, replacementString: item.text) {
179+ textView.replaceCharacters(in: replaceRange, with: item.text)
180+ textView.didChangeText()
181+ // Update the binding
182+ isEditing = true
183+ text.wrappedValue = textView.string
184+ applyHighlighting(to: textView)
185+ isEditing = false
186+ }
187+ }
188+
189+ private weak var lastTextView: NSTextView?
190+
191+ private func findTextView() -> NSTextView? {
192+ lastTextView
193+ }
194+
195+ /// Called from textDidChange / doCommandBy to record the active text view.
196+ func textDidBeginEditing(_ notification: Notification) {
197+ if let tv = notification.object as? NSTextView {
198+ lastTextView = tv
199+ }
200+ }
201+
202+ func textViewDidChangeSelection(_ notification: Notification) {
203+ if let tv = notification.object as? NSTextView {
204+ lastTextView = tv
205+ // Hide popup when cursor moves without typing (e.g. mouse click)
206+ if !isEditing && !isInsertingCompletion {
207+ completionPopup.hide()
208+ }
209+ }
210+ }
211+
212+ // MARK: - Helpers
213+
82214 func applyHighlighting(to textView: NSTextView) {
83215 guard let textStorage = textView.textStorage else { return }
84216 textStorage.beginEditing()
85217 SQLSyntaxHighlighter.highlight(textView.string, in: textStorage, baseFont: SQLEditorView.editorFont)
86218 textStorage.endEditing()
219+ // Keep track of the text view for completion insertion
220+ lastTextView = textView
221+ }
222+
223+ private func cursorScreenPoint(for textView: NSTextView) -> NSPoint {
224+ guard let layoutManager = textView.layoutManager,
225+ let container = textView.textContainer else {
226+ // Fallback: bottom-left of the text view
227+ let fallback = NSPoint(x: textView.frame.origin.x, y: textView.frame.maxY)
228+ let windowPoint = textView.convert(fallback, to: nil)
229+ return textView.window?.convertPoint(toScreen: windowPoint) ?? windowPoint
230+ }
231+ let cursorRange = textView.selectedRange()
232+ let glyphRange = layoutManager.glyphRange(
233+ forCharacterRange: NSRange(location: cursorRange.location, length: 0),
234+ actualCharacterRange: nil
235+ )
236+ let rect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: container)
237+ // textContainerOrigin already accounts for textContainerInset
238+ let origin = textView.textContainerOrigin
239+ let viewPoint = NSPoint(
240+ x: rect.origin.x + origin.x,
241+ y: rect.maxY + origin.y + 2
242+ )
243+ let windowPoint = textView.convert(viewPoint, to: nil)
244+ return textView.window?.convertPoint(toScreen: windowPoint) ?? windowPoint
87245 }
88246 }
89247}
ParseableViewerTests/SQLAutoCompleteTests.swiftnew file+281
@@ -0,0 +1,281 @@
1+import XCTest
2+@testable import ParseableViewer
3+
4+final class SQLAutoCompleteTests: XCTestCase {
5+
6+ private let streams = ["access_logs", "error_logs", "metrics"]
7+ private let fields = [
8+ SchemaField(name: "p_timestamp", dataType: "Utf8"),
9+ SchemaField(name: "level", dataType: "Utf8"),
10+ SchemaField(name: "message", dataType: "Utf8"),
11+ SchemaField(name: "host", dataType: "Utf8"),
12+ SchemaField(name: "status_code", dataType: "Int64"),
13+ ]
14+
15+ // MARK: - Context detection
16+
17+ func testContextAfterSelect() {
18+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT ", position: 7)
19+ XCTAssertEqual(ctx, .columnRef)
20+ }
21+
22+ func testContextAfterFrom() {
23+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM ", position: 14)
24+ XCTAssertEqual(ctx, .tableRef)
25+ }
26+
27+ func testContextAfterWhere() {
28+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t WHERE ", position: 22)
29+ XCTAssertEqual(ctx, .columnRef)
30+ }
31+
32+ func testContextAfterAnd() {
33+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t WHERE x = 1 AND ", position: 32)
34+ XCTAssertEqual(ctx, .columnRef)
35+ }
36+
37+ func testContextAfterJoin() {
38+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a JOIN ", position: 21)
39+ XCTAssertEqual(ctx, .tableRef)
40+ }
41+
42+ func testContextAfterOrder() {
43+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t ORDER ", position: 22)
44+ XCTAssertEqual(ctx, .afterOrder)
45+ }
46+
47+ func testContextAfterGroup() {
48+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t GROUP ", position: 22)
49+ XCTAssertEqual(ctx, .afterGroup)
50+ }
51+
52+ func testContextAfterCommaInSelect() {
53+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT a, ", position: 10)
54+ XCTAssertEqual(ctx, .columnRef)
55+ }
56+
57+ func testContextAfterCommaInFrom() {
58+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a, ", position: 17)
59+ XCTAssertEqual(ctx, .tableRef)
60+ }
61+
62+ func testContextGeneral() {
63+ let ctx = SQLCompletionProvider.determineContext(text: "", position: 0)
64+ XCTAssertEqual(ctx, .general)
65+ }
66+
67+ // MARK: - Completions for keywords
68+
69+ func testKeywordCompletionSelect() {
70+ let result = SQLCompletionProvider.completions(
71+ for: "SEL", cursorPosition: 3, streamNames: streams, schemaFields: fields
72+ )
73+ XCTAssertTrue(result.items.contains(where: { $0.text == "SELECT" }))
74+ XCTAssertEqual(result.prefix, "SEL")
75+ XCTAssertEqual(result.range, NSRange(location: 0, length: 3))
76+ }
77+
78+ func testKeywordCompletionCaseInsensitive() {
79+ let result = SQLCompletionProvider.completions(
80+ for: "sel", cursorPosition: 3, streamNames: streams, schemaFields: fields
81+ )
82+ XCTAssertTrue(result.items.contains(where: { $0.text == "SELECT" }))
83+ }
84+
85+ func testNoCompletionOnExactMatch() {
86+ let result = SQLCompletionProvider.completions(
87+ for: "SELECT", cursorPosition: 6, streamNames: [], schemaFields: []
88+ )
89+ // When the only match is exact, no items are returned
90+ XCTAssertTrue(result.items.isEmpty)
91+ }
92+
93+ // MARK: - Table name completions
94+
95+ func testTableCompletionAfterFrom() {
96+ let result = SQLCompletionProvider.completions(
97+ for: "SELECT * FROM ac", cursorPosition: 16, streamNames: streams, schemaFields: fields
98+ )
99+ XCTAssertTrue(result.items.contains(where: { $0.text == "\"access_logs\"" && $0.kind == .table }))
100+ XCTAssertFalse(result.items.contains(where: { $0.text == "\"metrics\"" }))
101+ }
102+
103+ func testTableCompletionIncludesQuotes() {
104+ let result = SQLCompletionProvider.completions(
105+ for: "SELECT * FROM err", cursorPosition: 17, streamNames: streams, schemaFields: fields
106+ )
107+ let tableItem = result.items.first(where: { $0.kind == .table })
108+ XCTAssertNotNil(tableItem)
109+ XCTAssertEqual(tableItem?.text, "\"error_logs\"")
110+ }
111+
112+ // MARK: - Column completions
113+
114+ func testColumnCompletionAfterWhere() {
115+ let result = SQLCompletionProvider.completions(
116+ for: "SELECT * FROM t WHERE lev", cursorPosition: 25,
117+ streamNames: streams, schemaFields: fields
118+ )
119+ XCTAssertTrue(result.items.contains(where: { $0.text == "level" && $0.kind == .column }))
120+ }
121+
122+ func testColumnCompletionIncludesDetail() {
123+ let result = SQLCompletionProvider.completions(
124+ for: "SELECT * FROM t WHERE sta", cursorPosition: 25,
125+ streamNames: streams, schemaFields: fields
126+ )
127+ let colItem = result.items.first(where: { $0.kind == .column })
128+ XCTAssertNotNil(colItem)
129+ XCTAssertEqual(colItem?.detail, "Int64")
130+ }
131+
132+ func testColumnCompletionAfterSelect() {
133+ let result = SQLCompletionProvider.completions(
134+ for: "SELECT mes", cursorPosition: 10,
135+ streamNames: streams, schemaFields: fields
136+ )
137+ XCTAssertTrue(result.items.contains(where: { $0.text == "message" && $0.kind == .column }))
138+ }
139+
140+ // MARK: - Function completions
141+
142+ func testFunctionCompletion() {
143+ let result = SQLCompletionProvider.completions(
144+ for: "SELECT COU", cursorPosition: 10,
145+ streamNames: streams, schemaFields: fields
146+ )
147+ XCTAssertTrue(result.items.contains(where: { $0.text == "COUNT" && $0.kind == .function }))
148+ }
149+
150+ // MARK: - Empty prefix returns nothing
151+
152+ func testEmptyPrefixNoCompletions() {
153+ let result = SQLCompletionProvider.completions(
154+ for: "SELECT ", cursorPosition: 7,
155+ streamNames: streams, schemaFields: fields
156+ )
157+ XCTAssertTrue(result.items.isEmpty)
158+ }
159+
160+ // MARK: - Prefix range
161+
162+ func testPrefixRangeInMiddle() {
163+ let text = "SELECT * FROM acc WHERE"
164+ let result = SQLCompletionProvider.completions(
165+ for: text, cursorPosition: 17,
166+ streamNames: streams, schemaFields: fields
167+ )
168+ // "acc" starts at position 14
169+ XCTAssertEqual(result.range, NSRange(location: 14, length: 3))
170+ XCTAssertEqual(result.prefix, "acc")
171+ }
172+
173+ // MARK: - ORDER BY / GROUP BY
174+
175+ func testOrderByCompletion() {
176+ let result = SQLCompletionProvider.completions(
177+ for: "SELECT * FROM t ORDER B", cursorPosition: 23,
178+ streamNames: streams, schemaFields: fields
179+ )
180+ XCTAssertTrue(result.items.contains(where: { $0.text == "BY" }))
181+ }
182+
183+ func testGroupByCompletion() {
184+ let result = SQLCompletionProvider.completions(
185+ for: "SELECT * FROM t GROUP B", cursorPosition: 23,
186+ streamNames: streams, schemaFields: fields
187+ )
188+ XCTAssertTrue(result.items.contains(where: { $0.text == "BY" }))
189+ }
190+
191+ // MARK: - isWordCharacter
192+
193+ func testWordCharacters() {
194+ XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x41)) // A
195+ XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x5A)) // Z
196+ XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x61)) // a
197+ XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x7A)) // z
198+ XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x30)) // 0
199+ XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x39)) // 9
200+ XCTAssertTrue(SQLCompletionProvider.isWordCharacter(0x5F)) // _
201+ XCTAssertFalse(SQLCompletionProvider.isWordCharacter(0x20)) // space
202+ XCTAssertFalse(SQLCompletionProvider.isWordCharacter(0x2C)) // comma
203+ XCTAssertFalse(SQLCompletionProvider.isWordCharacter(0x28)) // (
204+ }
205+
206+ // MARK: - Edge cases
207+
208+ func testCursorAtZero() {
209+ let result = SQLCompletionProvider.completions(
210+ for: "SELECT", cursorPosition: 0,
211+ streamNames: streams, schemaFields: fields
212+ )
213+ XCTAssertTrue(result.items.isEmpty)
214+ }
215+
216+ func testCursorBeyondEnd() {
217+ let result = SQLCompletionProvider.completions(
218+ for: "SEL", cursorPosition: 100,
219+ streamNames: streams, schemaFields: fields
220+ )
221+ XCTAssertTrue(result.items.isEmpty)
222+ }
223+
224+ func testEmptyText() {
225+ let result = SQLCompletionProvider.completions(
226+ for: "", cursorPosition: 0,
227+ streamNames: streams, schemaFields: fields
228+ )
229+ XCTAssertTrue(result.items.isEmpty)
230+ }
231+
232+ // MARK: - Mixed context completions
233+
234+ func testGeneralContextIncludesAllKinds() {
235+ // Typing "m" at the start should match keywords (no keywords start with m),
236+ // functions, tables ("metrics"), and columns ("message")
237+ let result = SQLCompletionProvider.completions(
238+ for: "m", cursorPosition: 1,
239+ streamNames: streams, schemaFields: fields
240+ )
241+ XCTAssertTrue(result.items.contains(where: { $0.kind == .function })) // MAX, MIN
242+ XCTAssertTrue(result.items.contains(where: { $0.kind == .table })) // metrics
243+ XCTAssertTrue(result.items.contains(where: { $0.kind == .column })) // message
244+ }
245+
246+ // MARK: - Completion item properties
247+
248+ func testCompletionItemKindLabels() {
249+ let kw = SQLCompletionItem(text: "SELECT", kind: .keyword)
250+ XCTAssertEqual(kw.kindLabel, "K")
251+
252+ let fn = SQLCompletionItem(text: "COUNT", kind: .function)
253+ XCTAssertEqual(fn.kindLabel, "F")
254+
255+ let tbl = SQLCompletionItem(text: "logs", kind: .table)
256+ XCTAssertEqual(tbl.kindLabel, "T")
257+
258+ let col = SQLCompletionItem(text: "level", kind: .column)
259+ XCTAssertEqual(col.kindLabel, "C")
260+ }
261+
262+ // MARK: - Context with DISTINCT, NOT, BETWEEN, etc.
263+
264+ func testContextAfterDistinct() {
265+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT DISTINCT ", position: 16)
266+ XCTAssertEqual(ctx, .columnRef)
267+ }
268+
269+ func testContextAfterNot() {
270+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM t WHERE NOT ", position: 26)
271+ XCTAssertEqual(ctx, .columnRef)
272+ }
273+
274+ func testContextAfterLeftJoin() {
275+ let ctx = SQLCompletionProvider.determineContext(text: "SELECT * FROM a LEFT JOIN ", position: 26)
276+ XCTAssertEqual(ctx, .tableRef)
277+ }
278+}
279+
280+// Make SQLCompletionContext Equatable for assertions
281+extension SQLCompletionContext: Equatable {}