@@ -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+}