diff --git a/CHANGELOG.md b/CHANGELOG.md index 553ae5ded..36bdd3989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Data grid now serves the row count from its existing cache instead of recomputing it on every layout pass, reducing CPU churn while scrolling large result sets. - Typing in the sidebar table search stays responsive on databases with thousands of tables; filtering runs after a short pause instead of on every keystroke. +- Autocomplete ranks each fuzzy candidate once per keystroke instead of three times, keeping the suggestion list snappy on wide SELECT clauses with hundreds of columns. ### Fixed diff --git a/TablePro/Core/Autocomplete/SQLCompletionItem.swift b/TablePro/Core/Autocomplete/SQLCompletionItem.swift index d86a08cc6..39e7030e3 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionItem.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionItem.swift @@ -77,6 +77,7 @@ struct SQLCompletionItem: Identifiable, Hashable { var sortPriority: Int // For ranking (lower = higher priority) let filterText: String // Text used for matching var matchedRanges: [Range] = [] + var fuzzyPenalty: Int = 0 init( label: String, diff --git a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index a94621f00..676653a37 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -89,7 +89,6 @@ final class SQLCompletionProvider { if !context.prefix.isEmpty { candidates = filterByPrefix(candidates, prefix: context.prefix) - populateMatchRanges(&candidates, prefix: context.prefix) } candidates = rankResults(candidates, prefix: context.prefix, context: context) @@ -531,87 +530,65 @@ final class SQLCompletionProvider { /// Filter and rank items by prefix, returning sorted results with match ranges func filterAndRank(_ items: [SQLCompletionItem], prefix: String, context: SQLContext) -> [SQLCompletionItem] { - var filtered = filterByPrefix(items, prefix: prefix) - // Clear stale match ranges before recomputing - for i in filtered.indices { filtered[i].matchedRanges = [] } - populateMatchRanges(&filtered, prefix: prefix) + let filtered = filterByPrefix(items, prefix: prefix) return rankResults(filtered, prefix: prefix, context: context) } - /// Filter candidates by prefix (case-insensitive) with fuzzy matching support + /// Filter candidates by prefix (case-insensitive) with fuzzy matching support. + /// Resolves `matchedRanges` and the fuzzy-only `fuzzyPenalty` in one pass per + /// candidate so `rankResults` never recomputes a fuzzy match. Both fields are + /// assigned (never accumulated), so re-filtering a prior result is idempotent. func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { - guard !prefix.isEmpty else { return items } - - let lowerPrefix = prefix.lowercased() - - return items.filter { item in - if item.filterText.hasPrefix(lowerPrefix) { - return true - } - - if item.filterText.contains(lowerPrefix) { - return true + guard !prefix.isEmpty else { + var reset = items + for i in reset.indices { + reset[i].matchedRanges = [] + reset[i].fuzzyPenalty = 0 } - - // Fuzzy match: check if all characters appear in order - return fuzzyMatch(pattern: lowerPrefix, target: item.filterText) + return reset } - } - - /// Fuzzy matching with scoring: returns penalty score (higher = worse), - /// nil = no match. Uses NSString character-at-index for O(1) random - /// access instead of Swift String indexing (LP-9). - func fuzzyMatchScore(pattern: String, target: String) -> Int? { - let nsPattern = pattern as NSString - let nsTarget = target as NSString - let patternLen = nsPattern.length - let targetLen = nsTarget.length - - guard patternLen > 0, targetLen > 0 else { return nil } - var patternIdx = 0 - var targetIdx = 0 - var gaps = 0 - var consecutiveMatches = 0 - var maxConsecutive = 0 - var lastMatchIdx = -1 - - while patternIdx < patternLen && targetIdx < targetLen { - let pChar = nsPattern.character(at: patternIdx) - let tChar = nsTarget.character(at: targetIdx) + let lowerPrefix = prefix.lowercased() + let nsPrefix = lowerPrefix as NSString - if pChar == tChar { - if lastMatchIdx == targetIdx - 1 { - consecutiveMatches += 1 - maxConsecutive = max(maxConsecutive, consecutiveMatches) - } else { - if lastMatchIdx >= 0 { - gaps += targetIdx - lastMatchIdx - 1 - } - consecutiveMatches = 1 - } - lastMatchIdx = targetIdx - patternIdx += 1 + var kept: [SQLCompletionItem] = [] + kept.reserveCapacity(items.count) + + for var item in items { + let nsFilterText = item.filterText as NSString + + if nsFilterText.range(of: lowerPrefix, options: .anchored).location != NSNotFound { + item.matchedRanges = [0.. Bool { - fuzzyMatchScore(pattern: pattern, target: target) != nil + /// NSString.range(of:) without the anchored option, returning a Swift Range + /// or nil when not found. Avoids re-bridging the result through NSNotFound. + private func optionalRange(of substring: String, in target: NSString) -> Range? { + let range = target.range(of: substring) + guard range.location != NSNotFound else { return nil } + return range.location..<(range.location + range.length) } - /// Fuzzy matching that returns both score and matched character indices - private func fuzzyMatchWithIndices(pattern: String, target: String) -> (score: Int, indices: [Int])? { + /// Single fuzzy pass that resolves match state, penalty score, and matched + /// character indices in one traversal. `filterByPrefix` calls this once per + /// candidate. Uses NSString character-at-index for O(1) random access instead + /// of Swift String indexing (LP-9). + private func resolveFuzzyMatch(pattern: String, target: String) -> (penalty: Int, indices: [Int])? { let nsPattern = pattern as NSString let nsTarget = target as NSString let patternLen = nsPattern.length @@ -626,6 +603,7 @@ final class SQLCompletionProvider { var maxConsecutive = 0 var lastMatchIdx = -1 var matchedIndices: [Int] = [] + matchedIndices.reserveCapacity(min(patternLen, targetLen)) while patternIdx < patternLen && targetIdx < targetLen { let pChar = nsPattern.character(at: patternIdx) @@ -653,30 +631,14 @@ final class SQLCompletionProvider { let basePenalty = 50 let gapPenalty = gaps * 10 let consecutiveBonus = maxConsecutive * 15 - let score = max(0, basePenalty + gapPenalty - consecutiveBonus) - return (score, matchedIndices) + let penalty = max(0, basePenalty + gapPenalty - consecutiveBonus) + return (penalty, matchedIndices) } - /// Populate matchedRanges on each item based on how it matched the prefix - private func populateMatchRanges(_ items: inout [SQLCompletionItem], prefix: String) { - guard !prefix.isEmpty else { return } - let lowerPrefix = prefix.lowercased() - let nsPrefix = lowerPrefix as NSString - - for i in items.indices { - let nsFilterText = items[i].filterText as NSString - let prefixRange = nsFilterText.range(of: lowerPrefix, options: .anchored) - if prefixRange.location != NSNotFound { - items[i].matchedRanges = [0.. Int? { + resolveFuzzyMatch(pattern: pattern, target: target)?.penalty } /// Convert sorted individual character indices into contiguous ranges @@ -711,9 +673,11 @@ final class SQLCompletionProvider { } } - /// Calculate ranking score for an item (lower = better) + /// Calculate ranking score for an item (lower = better). + /// The fuzzy-only penalty is precomputed into `fuzzyPenalty` by `filterByPrefix` + /// so the ranking comparator does not invoke fuzzy matching again. func calculateScore(for item: SQLCompletionItem, prefix: String, context: SQLContext) -> Int { - var score = item.sortPriority + var score = item.sortPriority + item.fuzzyPenalty if item.filterText.hasPrefix(prefix) { score -= 500 @@ -759,17 +723,6 @@ final class SQLCompletionProvider { // Shorter names slightly preferred score += (item.label as NSString).length - // Fuzzy match penalty — items matched only by fuzzy get demoted - if !prefix.isEmpty { - let filterText = item.filterText - if !filterText.hasPrefix(prefix) && !filterText.contains(prefix) { - // This is a fuzzy-only match — apply penalty - if let fuzzyPenalty = fuzzyMatchScore(pattern: prefix, target: filterText) { - score += fuzzyPenalty - } - } - } - return score } } diff --git a/TableProTests/Views/Editor/SQLCompletionProviderConcurrencyTests.swift b/TableProTests/Views/Editor/SQLCompletionProviderConcurrencyTests.swift index b17a69f01..b600e6135 100644 --- a/TableProTests/Views/Editor/SQLCompletionProviderConcurrencyTests.swift +++ b/TableProTests/Views/Editor/SQLCompletionProviderConcurrencyTests.swift @@ -8,8 +8,8 @@ // actor's synchronous fast path must not diverge. // -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("SQL Completion Provider Concurrency") @@ -96,5 +96,10 @@ struct SQLCompletionProviderConcurrencyTests { #expect(extendedLabels == directLabels) #expect(extended.count <= short.count) + + let context = makeContext(prefix: "se") + let rankedExtended = provider.rankResults(extended, prefix: "se", context: context) + let rankedDirect = provider.rankResults(direct, prefix: "se", context: context) + #expect(rankedExtended.map { $0.label } == rankedDirect.map { $0.label }) } } diff --git a/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift b/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift new file mode 100644 index 000000000..de278448f --- /dev/null +++ b/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift @@ -0,0 +1,186 @@ +// +// SQLCompletionProviderFuzzyDedupeTests.swift +// TableProTests +// +// Guards the invariant that filterByPrefix resolves fuzzy matching once per +// candidate and that the resulting order matches a from-scratch reference +// ranking. Catches regressions where the dedupe folds the fuzzy penalty into +// sortPriority incorrectly or where a step skips its fuzzy pass. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL Completion Fuzzy Dedupe") +struct SQLCompletionProviderFuzzyDedupeTests { + private func makeProvider() -> SQLCompletionProvider { + SQLCompletionProvider(schemaProvider: SQLSchemaProvider()) + } + + private func makeContext(prefix: String) -> SQLContext { + SQLContext( + clauseType: .unknown, + prefix: prefix, + prefixRange: 0.. [SQLCompletionItem] { + let lowerPrefix = prefix.lowercased() + return items.sorted { a, b in + referenceScore(for: a, prefix: lowerPrefix) < referenceScore(for: b, prefix: lowerPrefix) + } + } + + private func referenceScore(for item: SQLCompletionItem, prefix: String) -> Int { + var score = item.sortPriority + if item.filterText.hasPrefix(prefix) { score -= 500 } + if item.filterText == prefix { score -= 1_000 } + score += (item.label as NSString).length + if !prefix.isEmpty { + if !item.filterText.hasPrefix(prefix) && !item.filterText.contains(prefix) { + if let fuzzy = referenceFuzzyScore(pattern: prefix, target: item.filterText) { + score += fuzzy + } + } + } + return score + } + + private func referenceFuzzyScore(pattern: String, target: String) -> Int? { + let nsPattern = pattern as NSString + let nsTarget = target as NSString + let patternLen = nsPattern.length + let targetLen = nsTarget.length + guard patternLen > 0, targetLen > 0 else { return nil } + + var patternIdx = 0 + var targetIdx = 0 + var gaps = 0 + var consecutiveMatches = 0 + var maxConsecutive = 0 + var lastMatchIdx = -1 + + while patternIdx < patternLen && targetIdx < targetLen { + let pChar = nsPattern.character(at: patternIdx) + let tChar = nsTarget.character(at: targetIdx) + if pChar == tChar { + if lastMatchIdx == targetIdx - 1 { + consecutiveMatches += 1 + maxConsecutive = max(maxConsecutive, consecutiveMatches) + } else { + if lastMatchIdx >= 0 { + gaps += targetIdx - lastMatchIdx - 1 + } + consecutiveMatches = 1 + } + lastMatchIdx = targetIdx + patternIdx += 1 + } + targetIdx += 1 + } + guard patternIdx == patternLen else { return nil } + + let basePenalty = 50 + let gapPenalty = gaps * 10 + let consecutiveBonus = maxConsecutive * 15 + return max(0, basePenalty + gapPenalty - consecutiveBonus) + } + + @Test("filterAndRank order matches a from-scratch reference rank") + func orderMatchesReferenceRank() { + let provider = makeProvider() + let items: [SQLCompletionItem] = [ + "select", "set", "session", "schema", "savepoint", + "score", "scalar", "substring", "smallint", "show", + "sum", "system_user", "some" + ].map { SQLCompletionItem.keyword($0) } + + let prefixes = ["s", "se", "ses", "sch", "su", "sm", "sx", "slc"] + for prefix in prefixes { + let context = makeContext(prefix: prefix) + let actual = provider.filterAndRank(items, prefix: prefix, context: context) + let expected = referenceRank( + items.filter { item in + item.filterText.hasPrefix(prefix) + || item.filterText.contains(prefix) + || referenceFuzzyScore(pattern: prefix, target: item.filterText) != nil + }, + prefix: prefix + ) + let actualLabels = actual.map { $0.label } + let expectedLabels = expected.map { $0.label } + #expect(actualLabels == expectedLabels, "Prefix '\(prefix)' produced a different order") + } + } + + @Test("filterByPrefix populates matchedRanges for all surviving candidates") + func matchedRangesPopulatedAfterFilter() { + let provider = makeProvider() + let items = ["select", "set", "session", "schema", "savepoint", "scalar"] + .map { SQLCompletionItem.keyword($0) } + + let filtered = provider.filterByPrefix(items, prefix: "slc") + #expect(!filtered.isEmpty) + for item in filtered { + #expect(!item.matchedRanges.isEmpty, "matchedRanges missing for \(item.label)") + } + } + + @Test("filterByPrefix resets matchedRanges when prefix is empty") + func matchedRangesResetOnEmptyPrefix() { + let provider = makeProvider() + var items = ["select", "set"].map { SQLCompletionItem.keyword($0) } + items[0].matchedRanges = [0..<2] + + let filtered = provider.filterByPrefix(items, prefix: "") + for item in filtered { + #expect(item.matchedRanges.isEmpty) + } + } + + @Test("filterByPrefix records the fuzzy penalty without mutating sortPriority") + func fuzzyPenaltyRecordedOnce() { + let provider = makeProvider() + let items = ["ssl_certificate", "session_variables"] + .map { SQLCompletionItem.keyword($0) } + + let basePriority = SQLCompletionKind.keyword.basePriority + let filtered = provider.filterByPrefix(items, prefix: "slc") + + #expect(filtered.count == 1) + #expect(filtered[0].label == "SSL_CERTIFICATE") + let expectedPenalty = referenceFuzzyScore(pattern: "slc", target: "ssl_certificate") ?? 0 + #expect(filtered[0].sortPriority == basePriority) + #expect(filtered[0].fuzzyPenalty == expectedPenalty) + } + + @Test("re-filtering a prior result is idempotent for fuzzy candidates") + func reFilteringIsIdempotent() { + let provider = makeProvider() + let items = ["ssl_certificate", "scalar_function", "select"] + .map { SQLCompletionItem.keyword($0) } + + let once = provider.filterByPrefix(items, prefix: "slc") + let twice = provider.filterByPrefix(once, prefix: "slc") + let context = makeContext(prefix: "slc") + + let rankedOnce = provider.rankResults(once, prefix: "slc", context: context) + let rankedTwice = provider.rankResults(twice, prefix: "slc", context: context) + + #expect(once.map { $0.fuzzyPenalty } == twice.map { $0.fuzzyPenalty }) + #expect(once.map { $0.sortPriority } == twice.map { $0.sortPriority }) + #expect(rankedOnce.map { $0.label } == rankedTwice.map { $0.label }) + } +}