@@ -0,0 +1,422 @@
3+// MARK: - Completion Item
5+struct SQLCompletionItem: Hashable {
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
20+ init(text: String, kind: Kind, detail: String? = nil, insertText: String? = nil) {
24+ self.insertText = insertText ?? text
27+ var kindLabel: String {
29+ case .keyword: return "K"
30+ case .function: return "F"
31+ case .table: return "T"
32+ case .column: return "C"
36+ var kindColor: NSColor {
38+ case .keyword: return .systemBlue
39+ case .function: return .systemTeal
40+ case .table: return .systemGreen
41+ case .column: return .systemOrange
46+// MARK: - Completion Context
48+enum SQLCompletionContext {
56+// MARK: - Completion Provider
58+enum SQLCompletionProvider {
60+ /// Returns matching completion items, the typed prefix, and the NSRange of
61+ /// that prefix in the source text.
62+ static func completions(
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))
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) {
84+ let prefix = nsText.substring(with: NSRange(location: wordStart, length: cursorPosition - wordStart))
85+ let range = NSRange(location: wordStart, length: cursorPosition - wordStart)
87+ guard !prefix.isEmpty else {
88+ return ([], "", range)
91+ let context = determineContext(text: text, position: wordStart)
92+ let uppercasePrefix = prefix.uppercased()
94+ var items: [SQLCompletionItem] = []
98+ for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
99+ items.append(SQLCompletionItem(
100+ text: "\"\(name)\"",
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))
111+ for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
112+ items.append(SQLCompletionItem(text: fn, kind: .function))
114+ for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
115+ items.append(SQLCompletionItem(text: kw, kind: .keyword))
118+ case .afterOrder, .afterGroup:
119+ if "BY".hasPrefix(uppercasePrefix) {
120+ items.append(SQLCompletionItem(text: "BY", kind: .keyword))
124+ for kw in SQLSyntaxHighlighter.keywords.sorted() where kw.hasPrefix(uppercasePrefix) {
125+ items.append(SQLCompletionItem(text: kw, kind: .keyword))
127+ for fn in SQLSyntaxHighlighter.functions.sorted() where fn.hasPrefix(uppercasePrefix) {
128+ items.append(SQLCompletionItem(text: fn, kind: .function))
130+ for name in streamNames.sorted() where name.uppercased().hasPrefix(uppercasePrefix) {
131+ items.append(SQLCompletionItem(
132+ text: "\"\(name)\"",
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))
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)
148+ return (items, prefix, range)
151+ // MARK: - Context Detection
153+ static func determineContext(text: String, position: Int) -> SQLCompletionContext {
154+ let before = String(text.prefix(position))
155+ let tokens = tokenize(before)
157+ guard let lastToken = tokens.last?.uppercased() else {
162+ case "FROM", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "INTO":
164+ case "SELECT", "WHERE", "AND", "OR", "ON", "HAVING", "SET", "BY",
165+ "WHEN", "THEN", "ELSE", "CASE", "DISTINCT", "NOT", "BETWEEN", "LIKE", "IN", "IS":
172+ if lastToken == "," {
173+ for token in tokens.reversed() {
174+ let upper = token.uppercased()
175+ if ["SELECT", "BY", "WHERE", "HAVING", "ON"].contains(upper) {
178+ if ["FROM", "JOIN"].contains(upper) {
188+ // MARK: - Simple Tokenizer
190+ private static func tokenize(_ text: String) -> [String] {
191+ var tokens: [String] = []
193+ var inSingleQuote = false
194+ var inDoubleQuote = false
197+ if ch == "'" && !inDoubleQuote {
198+ inSingleQuote.toggle()
200+ } else if ch == "\"" && !inSingleQuote {
201+ inDoubleQuote.toggle()
203+ } else if inSingleQuote || inDoubleQuote {
205+ } else if ch == "," {
206+ let trimmed = current.trimmingCharacters(in: .whitespaces)
207+ if !trimmed.isEmpty { tokens.append(trimmed) }
210+ } else if ch == "(" || ch == ")" {
211+ let trimmed = current.trimmingCharacters(in: .whitespaces)
212+ if !trimmed.isEmpty { tokens.append(trimmed) }
213+ tokens.append(String(ch))
215+ } else if ch.isWhitespace {
216+ let trimmed = current.trimmingCharacters(in: .whitespaces)
217+ if !trimmed.isEmpty { tokens.append(trimmed) }
223+ // Do not include the in-progress word — it is the prefix we're completing
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
235+// MARK: - Completion Popup (child window with table view)
237+final class SQLCompletionPopup: NSObject, NSTableViewDataSource, NSTableViewDelegate {
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
246+ private(set) var items: [SQLCompletionItem] = []
247+ var selectedIndex: Int = 0
248+ var onAccept: ((SQLCompletionItem) -> Void)?
253+ styleMask: [.borderless, .nonactivatingPanel],
257+ panel.isFloatingPanel = true
258+ panel.hidesOnDeactivate = true
259+ panel.hasShadow = true
260+ panel.backgroundColor = .clear
261+ panel.isOpaque = false
262+ panel.level = .popUpMenu
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 = []
273+ let kindCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("kind"))
275+ kindCol.minWidth = 22
276+ kindCol.maxWidth = 22
277+ tableView.addTableColumn(kindCol)
279+ let textCol = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("text"))
280+ textCol.width = Self.panelWidth - 30
281+ tableView.addTableColumn(textCol)
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
296+ panel.contentView = scrollView
300+ tableView.dataSource = self
301+ tableView.delegate = self
302+ tableView.target = self
303+ tableView.doubleAction = #selector(doubleClicked)
306+ // MARK: - Public API
308+ var isVisible: Bool { panel.isVisible }
310+ func update(items: [SQLCompletionItem]) {
313+ tableView.reloadData()
321+ tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
322+ tableView.scrollRowToVisible(0)
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)
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)
343+ panel.parent?.removeChildWindow(panel)
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)
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)
361+ func acceptSelection() {
362+ guard selectedIndex >= 0, selectedIndex < items.count else { return }
363+ onAccept?(items[selectedIndex])
367+ // MARK: - NSTableViewDataSource
369+ func numberOfRows(in tableView: NSTableView) -> Int {
373+ // MARK: - NSTableViewDelegate
375+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
376+ guard row < items.count else { return nil }
377+ let item = items[row]
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 {
385+ cell = NSTextField(labelWithString: "")
386+ cell.identifier = id
387+ cell.alignment = .center
388+ cell.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
390+ cell.stringValue = item.kindLabel
391+ cell.textColor = item.kindColor
395+ let id = NSUserInterfaceItemIdentifier("TextCell")
396+ let cell: NSTextField
397+ if let reused = tableView.makeView(withIdentifier: id, owner: nil) as? NSTextField {
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
407+ if let detail = item.detail {
408+ cell.stringValue = "\(item.text) \(detail)"
410+ cell.stringValue = item.text
412+ cell.textColor = .labelColor
416+ @objc private func doubleClicked() {
417+ let row = tableView.clickedRow
418+ guard row >= 0, row < items.count else { return }