diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a045ce3e..d09e7f699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift index ac86af057..e238316fc 100644 --- a/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift +++ b/TablePro/Core/Autocomplete/SQLContextAnalyzer.swift @@ -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) @@ -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 @@ -531,7 +518,7 @@ final class SQLContextAnalyzer { } if !inString { - if Self.isIdentifierChar(ch) { + if SQLTokenBoundary.isIdentifierChar(ch) { if wordStart < 0 { wordStart = i } @@ -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?) { @@ -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 = [ diff --git a/TablePro/Core/Autocomplete/SQLTokenBoundary.swift b/TablePro/Core/Autocomplete/SQLTokenBoundary.swift new file mode 100644 index 000000000..998f666d4 --- /dev/null +++ b/TablePro/Core/Autocomplete/SQLTokenBoundary.swift @@ -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) + } +} diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index e2fb27bc1..c958f76e6 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -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 } @@ -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( diff --git a/TableProTests/Core/Autocomplete/SQLTokenBoundaryTests.swift b/TableProTests/Core/Autocomplete/SQLTokenBoundaryTests.swift new file mode 100644 index 000000000..d9b8f3ce3 --- /dev/null +++ b/TableProTests/Core/Autocomplete/SQLTokenBoundaryTests.swift @@ -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)) + } +}