Skip to content
Open
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 @@ -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

Expand Down
160 changes: 64 additions & 96 deletions TablePro/Core/Autocomplete/SQLCompletionProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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..<nsPrefix.length]
} else if let containsRange = optionalRange(of: lowerPrefix, in: nsFilterText) {
item.matchedRanges = [containsRange]
} else if let resolution = resolveFuzzyMatch(pattern: lowerPrefix, target: item.filterText) {
item.matchedRanges = indicesToRanges(resolution.indices)
item.sortPriority += resolution.penalty

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid storing fuzzy penalties in sortPriority

When completions are first opened while the current token is already a fuzzy-only match (for example sl matching SPLIT_PART), this mutates the returned SQLCompletionItems. SQLCompletionAdapter stores those items in the session and later calls filterAndRank on context.items as the cursor moves, so a longer fuzzy prefix adds another penalty on top of the old one instead of ranking from the base priority. This over-demotes fuzzy suggestions for the rest of that autocomplete session; keep the folded penalty out of the item or reset it before returning/caching.

Useful? React with 👍 / 👎.

} else {
continue
}

// Fuzzy match: check if all characters appear in order
return fuzzyMatch(pattern: lowerPrefix, target: item.filterText)
kept.append(item)
}

return kept
}

/// 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? {
/// 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<Int>? {
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
Expand All @@ -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)
Expand All @@ -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..<nsPrefix.length]
} else {
let containsRange = nsFilterText.range(of: lowerPrefix)
if containsRange.location != NSNotFound {
items[i].matchedRanges = [containsRange.location..<(containsRange.location + containsRange.length)]
} else if let result = fuzzyMatchWithIndices(pattern: lowerPrefix, target: items[i].filterText) {
items[i].matchedRanges = indicesToRanges(result.indices)
}
}
}
_ = items
_ = prefix
}

/// Convert sorted individual character indices into contiguous ranges
Expand Down Expand Up @@ -711,7 +688,9 @@ final class SQLCompletionProvider {
}
}

/// Calculate ranking score for an item (lower = better)
/// Calculate ranking score for an item (lower = better).
/// The fuzzy-only penalty is folded into `sortPriority` 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

Expand Down Expand Up @@ -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
}
}
Loading
Loading