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/SQLCompletionProvider.swift b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift index a94621f00..d21917af0 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,37 +530,60 @@ 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. + /// As a side effect this populates `matchedRanges` and folds the fuzzy-only + /// penalty into `sortPriority` once per candidate, so downstream steps + /// (`populateMatchRanges`, `rankResults`) do not recompute fuzzy matches. func filterByPrefix(_ items: [SQLCompletionItem], prefix: String) -> [SQLCompletionItem] { - guard !prefix.isEmpty else { return items } + guard !prefix.isEmpty else { + var reset = items + for i in reset.indices { reset[i].matchedRanges = [] } + return reset + } let lowerPrefix = prefix.lowercased() + let nsPrefix = lowerPrefix as NSString - return items.filter { item in - if item.filterText.hasPrefix(lowerPrefix) { - return true - } + var kept: [SQLCompletionItem] = [] + kept.reserveCapacity(items.count) - if item.filterText.contains(lowerPrefix) { - return true + for var item in items { + let nsFilterText = item.filterText as NSString + + if nsFilterText.range(of: lowerPrefix, options: .anchored).location != NSNotFound { + item.matchedRanges = [0.. Int? { + /// 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) + } + + /// Single fuzzy pass that resolves match state, penalty score, and matched + /// character indices in one traversal. `filterByPrefix` calls this once per + /// candidate; the older `fuzzyMatchScore` / `fuzzyMatchWithIndices` thin-wrap + /// it so existing callers (and tests) keep their behaviour. + 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 @@ -575,12 +597,15 @@ final class SQLCompletionProvider { var consecutiveMatches = 0 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) let tChar = nsTarget.character(at: targetIdx) if pChar == tChar { + matchedIndices.append(targetIdx) if lastMatchIdx == targetIdx - 1 { consecutiveMatches += 1 maxConsecutive = max(maxConsecutive, consecutiveMatches) @@ -598,85 +623,37 @@ final class SQLCompletionProvider { guard patternIdx == patternLen else { return nil } - // Score: base penalty + gap penalty - consecutive bonus let basePenalty = 50 let gapPenalty = gaps * 10 let consecutiveBonus = maxConsecutive * 15 - return max(0, basePenalty + gapPenalty - consecutiveBonus) + let penalty = max(0, basePenalty + gapPenalty - consecutiveBonus) + return (penalty, matchedIndices) + } + + /// 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? { + resolveFuzzyMatch(pattern: pattern, target: target)?.penalty } /// Backward-compatible fuzzy matching (Bool) for filterByPrefix private func fuzzyMatch(pattern: String, target: String) -> Bool { - fuzzyMatchScore(pattern: pattern, target: target) != nil + resolveFuzzyMatch(pattern: pattern, target: target) != nil } /// Fuzzy matching that returns both score and matched character indices private func fuzzyMatchWithIndices(pattern: String, target: String) -> (score: Int, indices: [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 - var matchedIndices: [Int] = [] - - while patternIdx < patternLen && targetIdx < targetLen { - let pChar = nsPattern.character(at: patternIdx) - let tChar = nsTarget.character(at: targetIdx) - - if pChar == tChar { - matchedIndices.append(targetIdx) - 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 - let score = max(0, basePenalty + gapPenalty - consecutiveBonus) - return (score, matchedIndices) + guard let resolution = resolveFuzzyMatch(pattern: pattern, target: target) else { return nil } + return (resolution.penalty, resolution.indices) } - /// Populate matchedRanges on each item based on how it matched the prefix + /// No-op retained for API stability. Match ranges are now populated by + /// `filterByPrefix` in its single fuzzy pass; calling this again would only + /// recompute ranges the filter already wrote. 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 { var score = item.sortPriority @@ -759,17 +738,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/SQLCompletionProviderFuzzyDedupeTests.swift b/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift new file mode 100644 index 000000000..87c36457a --- /dev/null +++ b/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift @@ -0,0 +1,167 @@ +// +// 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 +import TableProPluginKit +@testable import TablePro +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 folds fuzzy penalty into sortPriority once") + func fuzzyPenaltyFoldedOnce() { + 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 + expectedPenalty) + } +}