3// MARK: - Completion Item
5struct 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
48enum SQLCompletionContext {
56// MARK: - Completion Provider
58enum 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
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(
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.sortedFunctions where fn.hasPrefix(uppercasePrefix) {
112 items.append(SQLCompletionItem(text: fn, kind: .function))
114 for kw in SQLSyntaxHighlighter.sortedKeywords 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.sortedKeywords where kw.hasPrefix(uppercasePrefix) {
125 items.append(SQLCompletionItem(text: kw, kind: .keyword))
127 for fn in SQLSyntaxHighlighter.sortedFunctions 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(
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)
237final 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)
342 /// Moves an already-visible popup so it stays anchored to the caret as the
343 /// user keeps typing (otherwise it lingers at its original position).
344 func reposition(at screenPoint: NSPoint) {
345 guard panel.isVisible else { return }
346 let rowCount = min(items.count, Self.maxVisibleRows)
347 let height = CGFloat(rowCount) * Self.rowHeight + 4
348 let frame = NSRect(x: screenPoint.x, y: screenPoint.y - height,
349 width: Self.panelWidth, height: height)
350 panel.setFrame(frame, display: true)
354 panel.parent?.removeChildWindow(panel)
358 func moveSelectionUp() {
359 guard !items.isEmpty else { return }
360 selectedIndex = max(0, selectedIndex - 1)
361 tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false)
362 tableView.scrollRowToVisible(selectedIndex)
365 func moveSelectionDown() {
366 guard !items.isEmpty else { return }
367 selectedIndex = min(items.count - 1, selectedIndex + 1)
368 tableView.selectRowIndexes(IndexSet(integer: selectedIndex), byExtendingSelection: false)
369 tableView.scrollRowToVisible(selectedIndex)
372 func acceptSelection() {
373 guard selectedIndex >= 0, selectedIndex < items.count else { return }
374 onAccept?(items[selectedIndex])
378 // MARK: - NSTableViewDataSource
380 func numberOfRows(in tableView: NSTableView) -> Int {
384 // MARK: - NSTableViewDelegate
386 func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
387 guard row < items.count else { return nil }
388 let item = items[row]
390 if tableColumn?.identifier.rawValue == "kind" {
391 let id = NSUserInterfaceItemIdentifier("KindCell")
392 let cell: NSTextField
393 if let reused = tableView.makeView(withIdentifier: id, owner: nil) as? NSTextField {
396 cell = NSTextField(labelWithString: "")
398 cell.alignment = .center
399 cell.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .bold)
401 cell.stringValue = item.kindLabel
402 cell.textColor = item.kindColor
406 let id = NSUserInterfaceItemIdentifier("TextCell")
407 let cell: NSTextField
408 if let reused = tableView.makeView(withIdentifier: id, owner: nil) as? NSTextField {
411 cell = NSTextField(labelWithString: "")
413 cell.font = .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
414 cell.lineBreakMode = .byTruncatingTail
415 cell.maximumNumberOfLines = 1
418 if let detail = item.detail {
419 cell.stringValue = "\(item.text) \(detail)"
421 cell.stringValue = item.text
423 cell.textColor = .labelColor
427 @objc private func doubleClicked() {
428 let row = tableView.clickedRow
429 guard row >= 0, row < items.count else { return }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list