From 5dd31ecaa0155a451f1db86e59a145fa7969b42e Mon Sep 17 00:00:00 2001 From: xuhengyu Date: Sat, 20 Jun 2026 20:40:23 +0800 Subject: [PATCH 1/2] perf(inline-suggest): score completion candidates once instead of three times --- CHANGELOG.md | 1 + .../Autocomplete/SQLCompletionProvider.swift | 160 +++++++---------- ...QLCompletionProviderFuzzyDedupeTests.swift | 167 ++++++++++++++++++ 3 files changed, 232 insertions(+), 96 deletions(-) create mode 100644 TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift 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) + } +} From 8c2af0e6e60bb48735db2f2acaf245925feb1792 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 21 Jun 2026 20:21:27 +0700 Subject: [PATCH 2/2] refactor(inline-suggest): make fuzzy penalty idempotent and drop dead helpers --- .../Core/Autocomplete/SQLCompletionItem.swift | 1 + .../Autocomplete/SQLCompletionProvider.swift | 45 +++++++------------ ...QLCompletionProviderConcurrencyTests.swift | 7 ++- ...QLCompletionProviderFuzzyDedupeTests.swift | 27 +++++++++-- 4 files changed, 45 insertions(+), 35 deletions(-) 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 d21917af0..676653a37 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionProvider.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionProvider.swift @@ -535,13 +535,16 @@ final class SQLCompletionProvider { } /// 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. + /// 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 { var reset = items - for i in reset.indices { reset[i].matchedRanges = [] } + for i in reset.indices { + reset[i].matchedRanges = [] + reset[i].fuzzyPenalty = 0 + } return reset } @@ -556,11 +559,13 @@ final class SQLCompletionProvider { if nsFilterText.range(of: lowerPrefix, options: .anchored).location != NSNotFound { item.matchedRanges = [0.. (penalty: Int, indices: [Int])? { let nsPattern = pattern as NSString let nsTarget = target as NSString @@ -631,31 +636,11 @@ final class SQLCompletionProvider { } /// 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). + /// nil = no match. 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 { - 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])? { - guard let resolution = resolveFuzzyMatch(pattern: pattern, target: target) else { return nil } - return (resolution.penalty, resolution.indices) - } - - /// 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) { - _ = items - _ = prefix - } - /// Convert sorted individual character indices into contiguous ranges private func indicesToRanges(_ indices: [Int]) -> [Range] { guard !indices.isEmpty else { return [] } @@ -689,10 +674,10 @@ final class SQLCompletionProvider { } /// Calculate ranking score for an item (lower = better). - /// The fuzzy-only penalty is folded into `sortPriority` by `filterByPrefix` + /// 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 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 index 87c36457a..de278448f 100644 --- a/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift +++ b/TableProTests/Views/Editor/SQLCompletionProviderFuzzyDedupeTests.swift @@ -9,8 +9,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing @Suite("SQL Completion Fuzzy Dedupe") @@ -150,8 +150,8 @@ struct SQLCompletionProviderFuzzyDedupeTests { } } - @Test("filterByPrefix folds fuzzy penalty into sortPriority once") - func fuzzyPenaltyFoldedOnce() { + @Test("filterByPrefix records the fuzzy penalty without mutating sortPriority") + func fuzzyPenaltyRecordedOnce() { let provider = makeProvider() let items = ["ssl_certificate", "session_variables"] .map { SQLCompletionItem.keyword($0) } @@ -162,6 +162,25 @@ struct SQLCompletionProviderFuzzyDedupeTests { #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) + #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 }) } }