Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Accepting an autocomplete suggestion replaces the whole typed word; it could leave part of the word behind, turning `mess` plus Tab into `memessage`.
- MongoDB: connecting to Atlas no longer fails with TLS internal error (-9838); the plugin ships the OpenSSL TLS stack again. (#1599)
- DuckDB: the plugin runs DuckDB 1.5.2 again after a rollback to 1.5.0.
- JSON import: a failed import with "Delete existing rows before import" restores the deleted rows.
Expand Down
73 changes: 14 additions & 59 deletions TablePro/Core/Autocomplete/SQLContextAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,6 @@ final class SQLContextAnalyzer {
private static let openParen = UInt16(UnicodeScalar("(").value)
private static let closeParen = UInt16(UnicodeScalar(")").value)
private static let dot = UInt16(UnicodeScalar(".").value)
private static let backtick = UInt16(UnicodeScalar("`").value)
private static let underscore = UInt16(UnicodeScalar("_").value)
private static let comma = UInt16(UnicodeScalar(",").value)
private static let space = UInt16(UnicodeScalar(" ").value)
private static let tab = UInt16(UnicodeScalar("\t").value)
Expand Down Expand Up @@ -261,17 +259,6 @@ final class SQLContextAnalyzer {

// MARK: - UTF-16 Helpers

/// Check if a UTF-16 code unit is a letter or digit (ASCII fast path + fallback)
private static func isIdentifierChar(_ ch: UInt16) -> Bool {
// ASCII letters
if (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) { return true }
// ASCII digits
if ch >= 0x30 && ch <= 0x39 { return true }
// underscore
if ch == underscore { return true }
return false
}

/// Check if a UTF-16 code unit is whitespace (space, tab, newline, CR)
private static func isWhitespace(_ ch: UInt16) -> Bool {
ch == space || ch == tab || ch == newline || ch == cr
Expand Down Expand Up @@ -531,7 +518,7 @@ final class SQLContextAnalyzer {
}

if !inString {
if Self.isIdentifierChar(ch) {
if SQLTokenBoundary.isIdentifierChar(ch) {
if wordStart < 0 {
wordStart = i
}
Expand Down Expand Up @@ -702,7 +689,6 @@ final class SQLContextAnalyzer {
}

/// Extract the current word prefix and any dot prefix (table.column).
/// Uses NSString character-at-index for O(1) access instead of Array(text).
private func extractPrefix(
from text: String
) -> (prefix: String, start: Int, dotPrefix: String?) {
Expand All @@ -712,54 +698,23 @@ final class SQLContextAnalyzer {
return ("", 0, nil)
}

// Scan backwards to find start of identifier
var prefixStart = length
var foundDot = false
var dotPosition = -1

var i = length - 1
while i >= 0 {
let ch = ns.character(at: i)

if ch == Self.dot && !foundDot {
foundDot = true
dotPosition = i
i -= 1
continue
}
let start = SQLTokenBoundary.segmentStart(in: ns, endingAt: length)
let prefix = ns.substring(from: start)

if Self.isIdentifierChar(ch) || ch == Self.backtick || ch == Self.doubleQuote {
prefixStart = i
} else {
break
}

i -= 1
guard start > 0, ns.character(at: start - 1) == Self.dot else {
return (prefix, start, nil)
}

if foundDot && dotPosition > prefixStart {
// Has dot prefix like "users.na" or "u.na"
let beforeDotRange = NSRange(
location: prefixStart, length: dotPosition - prefixStart
)
let beforeDot = ns.substring(with: beforeDotRange)
let afterDotRange = NSRange(
location: dotPosition + 1, length: length - dotPosition - 1
)
let afterDot = ns.substring(with: afterDotRange)

let cleanDotPrefix = beforeDot.trimmingCharacters(
in: CharacterSet(charactersIn: "`\"")
)
return (afterDot, dotPosition + 1, cleanDotPrefix)
} else {
// No dot, just a regular prefix
let prefixRange = NSRange(
location: prefixStart, length: length - prefixStart
)
let prefix = ns.substring(with: prefixRange)
return (prefix, prefixStart, nil)
let qualifierEnd = start - 1
let qualifierStart = SQLTokenBoundary.segmentStart(in: ns, endingAt: qualifierEnd)
guard qualifierStart < qualifierEnd else {
return (prefix, start, nil)
}

let qualifierRange = NSRange(location: qualifierStart, length: qualifierEnd - qualifierStart)
let dotPrefix = ns.substring(with: qualifierRange)
.trimmingCharacters(in: CharacterSet(charactersIn: "`\""))
return (prefix, start, dotPrefix)
}

private static let tableRefKeywords: Set<String> = [
Expand Down
51 changes: 51 additions & 0 deletions TablePro/Core/Autocomplete/SQLTokenBoundary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// SQLTokenBoundary.swift
// TablePro
//
// Shared identifier-boundary rules for SQL completion. The context analyzer
// and the completion adapter must agree on where the token under the cursor
// starts, so both resolve it through this single implementation.
//

import Foundation

enum SQLTokenBoundary {
private static let dot = UInt16(UnicodeScalar(".").value)
private static let backtick = UInt16(UnicodeScalar("`").value)
private static let doubleQuote = UInt16(UnicodeScalar("\"").value)
private static let underscore = UInt16(UnicodeScalar("_").value)

static func isIdentifierChar(_ ch: UInt16) -> Bool {
if (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) { return true }
if ch >= 0x30 && ch <= 0x39 { return true }
return ch == underscore
}

static func isTokenChar(_ ch: UInt16) -> Bool {
isIdentifierChar(ch) || ch == backtick || ch == doubleQuote
}

/// Start of the identifier segment ending at `cursor`, scanning backward
/// over identifier and quote characters and stopping at a dot, so a
/// qualified name like `schema.tab` resolves to the segment after the dot.
static func segmentStart(in text: NSString, endingAt cursor: Int) -> Int {
let clamped = min(max(cursor, 0), text.length)
var start = clamped
var index = clamped - 1
while index >= 0 {
guard isTokenChar(text.character(at: index)) else { break }
start = index
index -= 1
}
return start
}

/// Replacement range for an accepted completion: the live segment under
/// the cursor when a cursor is available, otherwise the stored range
/// computed when the suggestion window opened.
static func replacementRange(in text: NSString?, cursor: Int?, fallback: NSRange) -> NSRange {
guard let text, let cursor, cursor >= 0, cursor <= text.length else { return fallback }
let start = segmentStart(in: text, endingAt: cursor)
return NSRange(location: start, length: cursor - start)
}
}
18 changes: 9 additions & 9 deletions TablePro/Views/Editor/SQLCompletionAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,15 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {
let provider = completionEngine?.provider else { return nil }

let offset = cursorPosition.range.location
let docLength = (textView.textView.textStorage?.string as NSString?)?.length ?? 0

let prefixStart = context.replacementRange.location
guard offset >= prefixStart, offset <= docLength else { return nil }
guard let nsText = textView.textView.textStorage?.string as NSString?,
offset >= 0, offset <= nsText.length else { return nil }

let prefixStart = SQLTokenBoundary.segmentStart(in: nsText, endingAt: offset)
let prefixLength = offset - prefixStart
guard prefixLength > 0, prefixLength <= 500 else { return nil }

let prefixRange = NSRange(location: prefixStart, length: prefixLength)
let currentPrefix = (textView.textView.textStorage?.string as NSString?)?
.substring(with: prefixRange).lowercased() ?? ""
let currentPrefix = nsText.substring(with: prefixRange).lowercased()

guard !currentPrefix.isEmpty else { return nil }

Expand All @@ -188,9 +186,11 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate {

suppressNextCompletion = true

let originalStart = context.replacementRange.location
let currentEnd = cursorPosition?.range.location ?? (originalStart + context.replacementRange.length)
let replaceRange = NSRange(location: originalStart, length: currentEnd - originalStart)
let replaceRange = SQLTokenBoundary.replacementRange(
in: textView.textView.textStorage?.string as NSString?,
cursor: cursorPosition?.range.location,
fallback: context.replacementRange
)
let insertText = entry.item.insertText

textView.textView.replaceCharacters(
Expand Down
99 changes: 99 additions & 0 deletions TableProTests/Core/Autocomplete/SQLTokenBoundaryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// SQLTokenBoundaryTests.swift
// TableProTests
//
// Tests for SQLTokenBoundary: the shared identifier-boundary rule used by
// the context analyzer and the completion adapter. Includes the regression
// for accepting a completion with a stale stored range (typing "mess" then
// Tab must produce "message", never "memessage").
//

import Foundation
@testable import TablePro
import Testing

@Suite("SQLTokenBoundary")
struct SQLTokenBoundaryTests {
@Test("Segment start covers the whole typed word")
func segmentStartPlainWord() {
let text = "SELECT mess" as NSString
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: 11) == 7)
}

@Test("Segment start stops at a dot so only the last segment is replaced")
func segmentStartAfterDot() {
let text = "SELECT users.na" as NSString
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: 15) == 13)
}

@Test("Segment start includes an opening identifier quote")
func segmentStartQuotedIdentifier() {
let text = "SELECT \"mess" as NSString
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: 12) == 7)
}

@Test("Segment start at a non-token position is the cursor itself")
func segmentStartAfterSpace() {
let text = "SELECT " as NSString
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: 7) == 7)
}

@Test("Segment start at document start")
func segmentStartAtZero() {
let text = "mess" as NSString
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: 0) == 0)
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: 4) == 0)
}

@Test("Segment start counts UTF-16 units with multibyte text before the token")
func segmentStartAfterMultibyteText() {
let text = "-- ghi chú 🙂\nSELECT mess" as NSString
let cursor = text.length
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: cursor) == cursor - 4)
}

@Test("Out-of-bounds cursor is clamped")
func segmentStartClampsCursor() {
let text = "mess" as NSString
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: 99) == 0)
#expect(SQLTokenBoundary.segmentStart(in: text, endingAt: -1) == 0)
}

@Test("Replacement range ignores a stale stored range and covers the live token")
func replacementRangeRecoversFromStaleContext() {
let text = "SELECT mess" as NSString
let stale = NSRange(location: 9, length: 2)
let range = SQLTokenBoundary.replacementRange(in: text, cursor: 11, fallback: stale)
#expect(range == NSRange(location: 7, length: 4))
let result = text.replacingCharacters(in: range, with: "message")
#expect(result == "SELECT message")
}

@Test("Replacement range falls back to the stored range without a cursor")
func replacementRangeFallsBackWithoutCursor() {
let text = "SELECT mess" as NSString
let stored = NSRange(location: 7, length: 4)
#expect(SQLTokenBoundary.replacementRange(in: text, cursor: nil, fallback: stored) == stored)
#expect(SQLTokenBoundary.replacementRange(in: nil, cursor: 11, fallback: stored) == stored)
}

@Test("Replacement range after a dot covers only the typed segment")
func replacementRangeAfterDot() {
let text = "SELECT users.na FROM users" as NSString
let range = SQLTokenBoundary.replacementRange(
in: text, cursor: 15, fallback: NSRange(location: 0, length: 0)
)
#expect(range == NSRange(location: 13, length: 2))
let result = text.replacingCharacters(in: range, with: "name")
#expect(result == "SELECT users.name FROM users")
}

@Test("Empty prefix inserts at the cursor")
func replacementRangeEmptyPrefix() {
let text = "SELECT " as NSString
let range = SQLTokenBoundary.replacementRange(
in: text, cursor: 7, fallback: NSRange(location: 3, length: 2)
)
#expect(range == NSRange(location: 7, length: 0))
}
}
Loading