7bb2e1aa58
- added Azure OpenAI Responses support and OpenRouter routing compat - improved patch applicator diagnostics and subagent context propagation - updated editor cursor handling, keybindings, and working message API - refreshed porting sync metadata
785 lines
26 KiB
TypeScript
785 lines
26 KiB
TypeScript
/**
|
|
* Fuzzy matching utilities for the edit tool.
|
|
*
|
|
* Provides both character-level and line-level fuzzy matching with progressive
|
|
* fallback strategies for finding text in files.
|
|
*/
|
|
import { countLeadingWhitespace, normalizeForFuzzy, normalizeUnicode } from "./normalize";
|
|
import type { ContextLineResult, FuzzyMatch, MatchOutcome, SequenceMatchStrategy, SequenceSearchResult } from "./types";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Constants
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Default similarity threshold for fuzzy matching */
|
|
export const DEFAULT_FUZZY_THRESHOLD = 0.95;
|
|
|
|
/** Threshold for sequence-based fuzzy matching */
|
|
const SEQUENCE_FUZZY_THRESHOLD = 0.92;
|
|
|
|
/** Fallback threshold for line-based matching */
|
|
const FALLBACK_THRESHOLD = 0.8;
|
|
|
|
/** Threshold for context line matching */
|
|
const CONTEXT_FUZZY_THRESHOLD = 0.8;
|
|
|
|
/** Minimum length for partial/substring matching */
|
|
const PARTIAL_MATCH_MIN_LENGTH = 6;
|
|
|
|
/** Minimum ratio of pattern to line length for substring match */
|
|
const PARTIAL_MATCH_MIN_RATIO = 0.3;
|
|
|
|
/** Context lines to show before/after an ambiguous match preview */
|
|
const OCCURRENCE_PREVIEW_CONTEXT = 5;
|
|
|
|
/** Maximum line length for ambiguous match previews */
|
|
const OCCURRENCE_PREVIEW_MAX_LEN = 80;
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Core Algorithms
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Compute Levenshtein distance between two strings */
|
|
export function levenshteinDistance(a: string, b: string): number {
|
|
if (a === b) return 0;
|
|
const aLen = a.length;
|
|
const bLen = b.length;
|
|
if (aLen === 0) return bLen;
|
|
if (bLen === 0) return aLen;
|
|
|
|
let prev = new Array<number>(bLen + 1);
|
|
let curr = new Array<number>(bLen + 1);
|
|
for (let j = 0; j <= bLen; j++) {
|
|
prev[j] = j;
|
|
}
|
|
|
|
for (let i = 1; i <= aLen; i++) {
|
|
curr[0] = i;
|
|
const aCode = a.charCodeAt(i - 1);
|
|
for (let j = 1; j <= bLen; j++) {
|
|
const cost = aCode === b.charCodeAt(j - 1) ? 0 : 1;
|
|
const deletion = prev[j] + 1;
|
|
const insertion = curr[j - 1] + 1;
|
|
const substitution = prev[j - 1] + cost;
|
|
curr[j] = Math.min(deletion, insertion, substitution);
|
|
}
|
|
const tmp = prev;
|
|
prev = curr;
|
|
curr = tmp;
|
|
}
|
|
|
|
return prev[bLen];
|
|
}
|
|
|
|
/** Compute similarity score between two strings (0 to 1) */
|
|
export function similarity(a: string, b: string): number {
|
|
if (a.length === 0 && b.length === 0) return 1;
|
|
const maxLen = Math.max(a.length, b.length);
|
|
if (maxLen === 0) return 1;
|
|
const distance = levenshteinDistance(a, b);
|
|
return 1 - distance / maxLen;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Line-Based Utilities
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Compute relative indent depths for lines */
|
|
function computeRelativeIndentDepths(lines: string[]): number[] {
|
|
const indents = lines.map(countLeadingWhitespace);
|
|
const nonEmptyIndents: number[] = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].trim().length > 0) {
|
|
nonEmptyIndents.push(indents[i]);
|
|
}
|
|
}
|
|
const minIndent = nonEmptyIndents.length > 0 ? Math.min(...nonEmptyIndents) : 0;
|
|
const indentSteps = nonEmptyIndents.map(indent => indent - minIndent).filter(step => step > 0);
|
|
const indentUnit = indentSteps.length > 0 ? Math.min(...indentSteps) : 1;
|
|
|
|
return lines.map((line, index) => {
|
|
if (line.trim().length === 0) return 0;
|
|
if (indentUnit <= 0) return 0;
|
|
const relativeIndent = indents[index] - minIndent;
|
|
return Math.round(relativeIndent / indentUnit);
|
|
});
|
|
}
|
|
|
|
/** Normalize lines for matching, optionally including indent depth */
|
|
function normalizeLines(lines: string[], includeDepth = true): string[] {
|
|
const indentDepths = includeDepth ? computeRelativeIndentDepths(lines) : null;
|
|
return lines.map((line, index) => {
|
|
const trimmed = line.trim();
|
|
const prefix = indentDepths ? `${indentDepths[index]}|` : "|";
|
|
if (trimmed.length === 0) return prefix;
|
|
return `${prefix}${normalizeForFuzzy(trimmed)}`;
|
|
});
|
|
}
|
|
|
|
/** Compute character offsets for each line in content */
|
|
function computeLineOffsets(lines: string[]): number[] {
|
|
const offsets: number[] = [];
|
|
let offset = 0;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
offsets.push(offset);
|
|
offset += lines[i].length;
|
|
if (i < lines.length - 1) offset += 1; // newline
|
|
}
|
|
return offsets;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Character-Level Fuzzy Match (for replace mode)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
interface BestFuzzyMatchResult {
|
|
best?: FuzzyMatch;
|
|
aboveThresholdCount: number;
|
|
secondBestScore: number;
|
|
}
|
|
|
|
function findBestFuzzyMatchCore(
|
|
contentLines: string[],
|
|
targetLines: string[],
|
|
offsets: number[],
|
|
threshold: number,
|
|
includeDepth: boolean,
|
|
): BestFuzzyMatchResult {
|
|
const targetNormalized = normalizeLines(targetLines, includeDepth);
|
|
|
|
let best: FuzzyMatch | undefined;
|
|
let bestScore = -1;
|
|
let secondBestScore = -1;
|
|
let aboveThresholdCount = 0;
|
|
|
|
for (let start = 0; start <= contentLines.length - targetLines.length; start++) {
|
|
const windowLines = contentLines.slice(start, start + targetLines.length);
|
|
const windowNormalized = normalizeLines(windowLines, includeDepth);
|
|
let score = 0;
|
|
for (let i = 0; i < targetLines.length; i++) {
|
|
score += similarity(targetNormalized[i], windowNormalized[i]);
|
|
}
|
|
score = score / targetLines.length;
|
|
|
|
if (score >= threshold) {
|
|
aboveThresholdCount++;
|
|
}
|
|
|
|
if (score > bestScore) {
|
|
secondBestScore = bestScore;
|
|
bestScore = score;
|
|
best = {
|
|
actualText: windowLines.join("\n"),
|
|
startIndex: offsets[start],
|
|
startLine: start + 1,
|
|
confidence: score,
|
|
};
|
|
} else if (score > secondBestScore) {
|
|
secondBestScore = score;
|
|
}
|
|
}
|
|
|
|
return { best, aboveThresholdCount, secondBestScore };
|
|
}
|
|
|
|
function findBestFuzzyMatch(content: string, target: string, threshold: number): BestFuzzyMatchResult {
|
|
const contentLines = content.split("\n");
|
|
const targetLines = target.split("\n");
|
|
|
|
if (targetLines.length === 0 || target.length === 0) {
|
|
return { aboveThresholdCount: 0, secondBestScore: 0 };
|
|
}
|
|
if (targetLines.length > contentLines.length) {
|
|
return { aboveThresholdCount: 0, secondBestScore: 0 };
|
|
}
|
|
|
|
const offsets = computeLineOffsets(contentLines);
|
|
let result = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, true);
|
|
|
|
// Retry without indent depth if match is close but below threshold
|
|
if (result.best && result.best.confidence < threshold && result.best.confidence >= FALLBACK_THRESHOLD) {
|
|
const noDepthResult = findBestFuzzyMatchCore(contentLines, targetLines, offsets, threshold, false);
|
|
if (noDepthResult.best && noDepthResult.best.confidence > result.best.confidence) {
|
|
result = noDepthResult;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Find a match for target text within content.
|
|
* Used primarily for replace-mode edits.
|
|
*/
|
|
export function findMatch(
|
|
content: string,
|
|
target: string,
|
|
options: { allowFuzzy: boolean; threshold?: number },
|
|
): MatchOutcome {
|
|
if (target.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
// Try exact match first
|
|
const exactIndex = content.indexOf(target);
|
|
if (exactIndex !== -1) {
|
|
const occurrences = content.split(target).length - 1;
|
|
if (occurrences > 1) {
|
|
// Find line numbers and previews for each occurrence (up to 5)
|
|
const contentLines = content.split("\n");
|
|
const occurrenceLines: number[] = [];
|
|
const occurrencePreviews: string[] = [];
|
|
let searchStart = 0;
|
|
for (let i = 0; i < 5; i++) {
|
|
const idx = content.indexOf(target, searchStart);
|
|
if (idx === -1) break;
|
|
const lineNumber = content.slice(0, idx).split("\n").length;
|
|
occurrenceLines.push(lineNumber);
|
|
const start = Math.max(0, lineNumber - 1 - OCCURRENCE_PREVIEW_CONTEXT);
|
|
const end = Math.min(contentLines.length, lineNumber + OCCURRENCE_PREVIEW_CONTEXT + 1);
|
|
const previewLines = contentLines.slice(start, end);
|
|
const preview = previewLines
|
|
.map((line, idx) => {
|
|
const num = start + idx + 1;
|
|
return ` ${num} | ${line.length > OCCURRENCE_PREVIEW_MAX_LEN ? `${line.slice(0, OCCURRENCE_PREVIEW_MAX_LEN - 3)}...` : line}`;
|
|
})
|
|
.join("\n");
|
|
occurrencePreviews.push(preview);
|
|
searchStart = idx + 1;
|
|
}
|
|
return { occurrences, occurrenceLines, occurrencePreviews };
|
|
}
|
|
const startLine = content.slice(0, exactIndex).split("\n").length;
|
|
return {
|
|
match: {
|
|
actualText: target,
|
|
startIndex: exactIndex,
|
|
startLine,
|
|
confidence: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Try fuzzy match
|
|
const threshold = options.threshold ?? DEFAULT_FUZZY_THRESHOLD;
|
|
const { best, aboveThresholdCount, secondBestScore } = findBestFuzzyMatch(content, target, threshold);
|
|
|
|
if (!best) {
|
|
return {};
|
|
}
|
|
|
|
if (options.allowFuzzy && best.confidence >= threshold) {
|
|
if (aboveThresholdCount === 1) {
|
|
return { match: best, closest: best };
|
|
}
|
|
const dominantDelta = 0.08;
|
|
const dominantMin = 0.97;
|
|
if (
|
|
aboveThresholdCount > 1 &&
|
|
best.confidence >= dominantMin &&
|
|
best.confidence - secondBestScore >= dominantDelta
|
|
) {
|
|
return { match: best, closest: best, fuzzyMatches: aboveThresholdCount, dominantFuzzy: true };
|
|
}
|
|
}
|
|
|
|
return { closest: best, fuzzyMatches: aboveThresholdCount };
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Line-Based Sequence Match (for patch mode)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Check if pattern matches lines starting at index using comparison function */
|
|
function matchesAt(lines: string[], pattern: string[], i: number, compare: (a: string, b: string) => boolean): boolean {
|
|
for (let j = 0; j < pattern.length; j++) {
|
|
if (!compare(lines[i + j], pattern[j])) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Compute average similarity score for pattern at position */
|
|
function fuzzyScoreAt(lines: string[], pattern: string[], i: number): number {
|
|
let totalScore = 0;
|
|
for (let j = 0; j < pattern.length; j++) {
|
|
const lineNorm = normalizeForFuzzy(lines[i + j]);
|
|
const patternNorm = normalizeForFuzzy(pattern[j]);
|
|
totalScore += similarity(lineNorm, patternNorm);
|
|
}
|
|
return totalScore / pattern.length;
|
|
}
|
|
|
|
/** Check if line starts with pattern (normalized) */
|
|
function lineStartsWithPattern(line: string, pattern: string): boolean {
|
|
const lineNorm = normalizeForFuzzy(line);
|
|
const patternNorm = normalizeForFuzzy(pattern);
|
|
if (patternNorm.length === 0) return lineNorm.length === 0;
|
|
return lineNorm.startsWith(patternNorm);
|
|
}
|
|
|
|
/** Check if line contains pattern as significant substring */
|
|
function lineIncludesPattern(line: string, pattern: string): boolean {
|
|
const lineNorm = normalizeForFuzzy(line);
|
|
const patternNorm = normalizeForFuzzy(pattern);
|
|
if (patternNorm.length === 0) return lineNorm.length === 0;
|
|
if (patternNorm.length < PARTIAL_MATCH_MIN_LENGTH) return false;
|
|
if (!lineNorm.includes(patternNorm)) return false;
|
|
return patternNorm.length / Math.max(1, lineNorm.length) >= PARTIAL_MATCH_MIN_RATIO;
|
|
}
|
|
|
|
function stripCommentPrefix(line: string): string {
|
|
let trimmed = line.trimStart();
|
|
if (trimmed.startsWith("/*")) {
|
|
trimmed = trimmed.slice(2);
|
|
} else if (trimmed.startsWith("*/")) {
|
|
trimmed = trimmed.slice(2);
|
|
} else if (trimmed.startsWith("//")) {
|
|
trimmed = trimmed.slice(2);
|
|
} else if (trimmed.startsWith("*")) {
|
|
trimmed = trimmed.slice(1);
|
|
} else if (trimmed.startsWith("#")) {
|
|
trimmed = trimmed.slice(1);
|
|
} else if (trimmed.startsWith(";")) {
|
|
trimmed = trimmed.slice(1);
|
|
} else if (trimmed.startsWith("/") && trimmed[1] === " ") {
|
|
trimmed = trimmed.slice(1);
|
|
}
|
|
return trimmed.trimStart();
|
|
}
|
|
|
|
/**
|
|
* Find a sequence of pattern lines within content lines.
|
|
*
|
|
* Attempts matches with decreasing strictness:
|
|
* 1. Exact match
|
|
* 2. Trailing whitespace ignored
|
|
* 3. All whitespace trimmed
|
|
* 4. Unicode punctuation normalized
|
|
* 5. Prefix match (pattern is prefix of line)
|
|
* 6. Substring match (pattern is substring of line)
|
|
* 7. Fuzzy similarity match
|
|
*
|
|
* @param lines - The lines of the file content
|
|
* @param pattern - The lines to search for
|
|
* @param start - Starting index for the search
|
|
* @param eof - If true, prefer matching at end of file first
|
|
*/
|
|
export function seekSequence(
|
|
lines: string[],
|
|
pattern: string[],
|
|
start: number,
|
|
eof: boolean,
|
|
options?: { allowFuzzy?: boolean },
|
|
): SequenceSearchResult {
|
|
const allowFuzzy = options?.allowFuzzy ?? true;
|
|
// Empty pattern matches immediately
|
|
if (pattern.length === 0) {
|
|
return { index: start, confidence: 1.0, strategy: "exact" };
|
|
}
|
|
|
|
// Pattern longer than available content cannot match
|
|
if (pattern.length > lines.length) {
|
|
return { index: undefined, confidence: 0 };
|
|
}
|
|
|
|
// Determine search start position
|
|
const searchStart = eof && lines.length >= pattern.length ? lines.length - pattern.length : start;
|
|
const maxStart = lines.length - pattern.length;
|
|
|
|
const runExactPasses = (from: number, to: number): SequenceSearchResult | undefined => {
|
|
// Pass 1: Exact match
|
|
for (let i = from; i <= to; i++) {
|
|
if (matchesAt(lines, pattern, i, (a, b) => a === b)) {
|
|
return { index: i, confidence: 1.0, strategy: "exact" };
|
|
}
|
|
}
|
|
|
|
// Pass 2: Trailing whitespace stripped
|
|
for (let i = from; i <= to; i++) {
|
|
if (matchesAt(lines, pattern, i, (a, b) => a.trimEnd() === b.trimEnd())) {
|
|
return { index: i, confidence: 0.99, strategy: "trim-trailing" };
|
|
}
|
|
}
|
|
|
|
// Pass 3: Both leading and trailing whitespace stripped
|
|
for (let i = from; i <= to; i++) {
|
|
if (matchesAt(lines, pattern, i, (a, b) => a.trim() === b.trim())) {
|
|
return { index: i, confidence: 0.98, strategy: "trim" };
|
|
}
|
|
}
|
|
|
|
// Pass 3b: Comment-prefix normalized match
|
|
for (let i = from; i <= to; i++) {
|
|
if (matchesAt(lines, pattern, i, (a, b) => stripCommentPrefix(a) === stripCommentPrefix(b))) {
|
|
return { index: i, confidence: 0.975, strategy: "comment-prefix" };
|
|
}
|
|
}
|
|
|
|
// Pass 4: Normalize unicode punctuation
|
|
for (let i = from; i <= to; i++) {
|
|
if (matchesAt(lines, pattern, i, (a, b) => normalizeUnicode(a) === normalizeUnicode(b))) {
|
|
return { index: i, confidence: 0.97, strategy: "unicode" };
|
|
}
|
|
}
|
|
|
|
if (!allowFuzzy) {
|
|
return undefined;
|
|
}
|
|
|
|
// Pass 5: Partial line prefix match (track all matches for ambiguity detection)
|
|
{
|
|
let firstMatch: number | undefined;
|
|
let matchCount = 0;
|
|
const matchIndices: number[] = [];
|
|
for (let i = from; i <= to; i++) {
|
|
if (matchesAt(lines, pattern, i, lineStartsWithPattern)) {
|
|
if (firstMatch === undefined) firstMatch = i;
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
}
|
|
if (matchCount > 0) {
|
|
return { index: firstMatch, confidence: 0.965, matchCount, matchIndices, strategy: "prefix" };
|
|
}
|
|
}
|
|
|
|
// Pass 6: Partial line substring match (track all matches for ambiguity detection)
|
|
{
|
|
let firstMatch: number | undefined;
|
|
let matchCount = 0;
|
|
const matchIndices: number[] = [];
|
|
for (let i = from; i <= to; i++) {
|
|
if (matchesAt(lines, pattern, i, lineIncludesPattern)) {
|
|
if (firstMatch === undefined) firstMatch = i;
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
}
|
|
if (matchCount > 0) {
|
|
return { index: firstMatch, confidence: 0.94, matchCount, matchIndices, strategy: "substring" };
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
const primaryPassResult = runExactPasses(searchStart, maxStart);
|
|
if (primaryPassResult) {
|
|
return primaryPassResult;
|
|
}
|
|
|
|
if (eof && searchStart > start) {
|
|
const fromStartResult = runExactPasses(start, maxStart);
|
|
if (fromStartResult) {
|
|
return fromStartResult;
|
|
}
|
|
}
|
|
|
|
if (!allowFuzzy) {
|
|
return { index: undefined, confidence: 0 };
|
|
}
|
|
|
|
// Pass 7: Fuzzy matching - find best match above threshold
|
|
let bestIndex: number | undefined;
|
|
let bestScore = 0;
|
|
let secondBestScore = 0;
|
|
let matchCount = 0;
|
|
const matchIndices: number[] = [];
|
|
|
|
for (let i = searchStart; i <= maxStart; i++) {
|
|
const score = fuzzyScoreAt(lines, pattern, i);
|
|
if (score >= SEQUENCE_FUZZY_THRESHOLD) {
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
if (score > bestScore) {
|
|
secondBestScore = bestScore;
|
|
bestScore = score;
|
|
bestIndex = i;
|
|
} else if (score > secondBestScore) {
|
|
secondBestScore = score;
|
|
}
|
|
}
|
|
|
|
// Also search from start if eof mode started from end
|
|
if (eof && searchStart > start) {
|
|
for (let i = start; i < searchStart; i++) {
|
|
const score = fuzzyScoreAt(lines, pattern, i);
|
|
if (score >= SEQUENCE_FUZZY_THRESHOLD) {
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
if (score > bestScore) {
|
|
secondBestScore = bestScore;
|
|
bestScore = score;
|
|
bestIndex = i;
|
|
} else if (score > secondBestScore) {
|
|
secondBestScore = score;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestIndex !== undefined && bestScore >= SEQUENCE_FUZZY_THRESHOLD) {
|
|
const dominantDelta = 0.08;
|
|
const dominantMin = 0.97;
|
|
if (matchCount > 1 && bestScore >= dominantMin && bestScore - secondBestScore >= dominantDelta) {
|
|
return {
|
|
index: bestIndex,
|
|
confidence: bestScore,
|
|
matchCount: 1,
|
|
matchIndices,
|
|
strategy: "fuzzy-dominant",
|
|
};
|
|
}
|
|
return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" };
|
|
}
|
|
|
|
// Pass 8: Character-based fuzzy matching via findMatch
|
|
// This is the final fallback for when line-based matching fails
|
|
const CHARACTER_MATCH_THRESHOLD = 0.92;
|
|
const patternText = pattern.join("\n");
|
|
const contentText = lines.slice(start).join("\n");
|
|
const matchOutcome = findMatch(contentText, patternText, {
|
|
allowFuzzy: true,
|
|
threshold: CHARACTER_MATCH_THRESHOLD,
|
|
});
|
|
|
|
if (matchOutcome.match) {
|
|
// Convert character index back to line index
|
|
const matchedContent = contentText.substring(0, matchOutcome.match.startIndex);
|
|
const lineIndex = start + matchedContent.split("\n").length - 1;
|
|
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches ?? 1;
|
|
return {
|
|
index: lineIndex,
|
|
confidence: matchOutcome.match.confidence,
|
|
matchCount: fallbackMatchCount,
|
|
strategy: "character",
|
|
};
|
|
}
|
|
|
|
const fallbackMatchCount = matchOutcome.occurrences ?? matchOutcome.fuzzyMatches;
|
|
return { index: undefined, confidence: bestScore, matchCount: fallbackMatchCount };
|
|
}
|
|
|
|
export function findClosestSequenceMatch(
|
|
lines: string[],
|
|
pattern: string[],
|
|
options?: { start?: number; eof?: boolean },
|
|
): { index: number | undefined; confidence: number; strategy: SequenceMatchStrategy } {
|
|
if (pattern.length === 0) {
|
|
return { index: options?.start ?? 0, confidence: 1, strategy: "exact" };
|
|
}
|
|
if (pattern.length > lines.length) {
|
|
return { index: undefined, confidence: 0, strategy: "fuzzy" };
|
|
}
|
|
|
|
const start = options?.start ?? 0;
|
|
const eof = options?.eof ?? false;
|
|
const maxStart = lines.length - pattern.length;
|
|
const searchStart = eof && lines.length >= pattern.length ? maxStart : start;
|
|
|
|
let bestIndex: number | undefined;
|
|
let bestScore = 0;
|
|
|
|
for (let i = searchStart; i <= maxStart; i++) {
|
|
const score = fuzzyScoreAt(lines, pattern, i);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
|
|
if (eof && searchStart > start) {
|
|
for (let i = start; i < searchStart; i++) {
|
|
const score = fuzzyScoreAt(lines, pattern, i);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { index: bestIndex, confidence: bestScore, strategy: "fuzzy" };
|
|
}
|
|
|
|
/**
|
|
* Find a context line in the file using progressive matching strategies.
|
|
*
|
|
* @param lines - The lines of the file content
|
|
* @param context - The context line to search for
|
|
* @param startFrom - Starting index for the search
|
|
*/
|
|
export function findContextLine(
|
|
lines: string[],
|
|
context: string,
|
|
startFrom: number,
|
|
options?: { allowFuzzy?: boolean; skipFunctionFallback?: boolean },
|
|
): ContextLineResult {
|
|
const allowFuzzy = options?.allowFuzzy ?? true;
|
|
const trimmedContext = context.trim();
|
|
|
|
// Pass 1: Exact line match
|
|
{
|
|
let firstMatch: number | undefined;
|
|
let matchCount = 0;
|
|
const matchIndices: number[] = [];
|
|
for (let i = startFrom; i < lines.length; i++) {
|
|
if (lines[i] === context) {
|
|
if (firstMatch === undefined) firstMatch = i;
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
}
|
|
if (matchCount > 0) {
|
|
return { index: firstMatch, confidence: 1.0, matchCount, matchIndices, strategy: "exact" };
|
|
}
|
|
}
|
|
|
|
// Pass 2: Trimmed match
|
|
{
|
|
let firstMatch: number | undefined;
|
|
let matchCount = 0;
|
|
const matchIndices: number[] = [];
|
|
for (let i = startFrom; i < lines.length; i++) {
|
|
if (lines[i].trim() === trimmedContext) {
|
|
if (firstMatch === undefined) firstMatch = i;
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
}
|
|
if (matchCount > 0) {
|
|
return { index: firstMatch, confidence: 0.99, matchCount, matchIndices, strategy: "trim" };
|
|
}
|
|
}
|
|
|
|
// Pass 3: Unicode normalization match
|
|
const normalizedContext = normalizeUnicode(context);
|
|
{
|
|
let firstMatch: number | undefined;
|
|
let matchCount = 0;
|
|
const matchIndices: number[] = [];
|
|
for (let i = startFrom; i < lines.length; i++) {
|
|
if (normalizeUnicode(lines[i]) === normalizedContext) {
|
|
if (firstMatch === undefined) firstMatch = i;
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
}
|
|
if (matchCount > 0) {
|
|
return { index: firstMatch, confidence: 0.98, matchCount, matchIndices, strategy: "unicode" };
|
|
}
|
|
}
|
|
|
|
if (!allowFuzzy) {
|
|
return { index: undefined, confidence: 0 };
|
|
}
|
|
|
|
// Pass 4: Prefix match (file line starts with context)
|
|
const contextNorm = normalizeForFuzzy(context);
|
|
if (contextNorm.length > 0) {
|
|
let firstMatch: number | undefined;
|
|
let matchCount = 0;
|
|
const matchIndices: number[] = [];
|
|
for (let i = startFrom; i < lines.length; i++) {
|
|
const lineNorm = normalizeForFuzzy(lines[i]);
|
|
if (lineNorm.startsWith(contextNorm)) {
|
|
if (firstMatch === undefined) firstMatch = i;
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
}
|
|
if (matchCount > 0) {
|
|
return { index: firstMatch, confidence: 0.96, matchCount, matchIndices, strategy: "prefix" };
|
|
}
|
|
}
|
|
|
|
// Pass 5: Substring match (file line contains context)
|
|
// First pass: find all substring matches (ignoring ratio)
|
|
// If exactly one match exists, accept it (uniqueness is sufficient)
|
|
// If multiple matches, apply ratio filter to disambiguate
|
|
if (contextNorm.length >= PARTIAL_MATCH_MIN_LENGTH) {
|
|
const allSubstringMatches: Array<{ index: number; ratio: number }> = [];
|
|
for (let i = startFrom; i < lines.length; i++) {
|
|
const lineNorm = normalizeForFuzzy(lines[i]);
|
|
if (lineNorm.includes(contextNorm)) {
|
|
const ratio = contextNorm.length / Math.max(1, lineNorm.length);
|
|
allSubstringMatches.push({ index: i, ratio });
|
|
}
|
|
}
|
|
const matchIndices = allSubstringMatches.slice(0, 5).map(match => match.index);
|
|
|
|
// If exactly one substring match, accept it regardless of ratio
|
|
if (allSubstringMatches.length === 1) {
|
|
return {
|
|
index: allSubstringMatches[0].index,
|
|
confidence: 0.94,
|
|
matchCount: 1,
|
|
matchIndices,
|
|
strategy: "substring",
|
|
};
|
|
}
|
|
|
|
// Multiple matches: filter by ratio to disambiguate
|
|
let firstMatch: number | undefined;
|
|
let matchCount = 0;
|
|
for (const match of allSubstringMatches) {
|
|
if (match.ratio >= PARTIAL_MATCH_MIN_RATIO) {
|
|
if (firstMatch === undefined) firstMatch = match.index;
|
|
matchCount++;
|
|
}
|
|
}
|
|
if (matchCount > 0) {
|
|
return { index: firstMatch, confidence: 0.94, matchCount, matchIndices, strategy: "substring" };
|
|
}
|
|
|
|
// If we had substring matches but none passed ratio filter,
|
|
// return ambiguous result so caller knows matches exist
|
|
if (allSubstringMatches.length > 1) {
|
|
return {
|
|
index: allSubstringMatches[0].index,
|
|
confidence: 0.94,
|
|
matchCount: allSubstringMatches.length,
|
|
matchIndices,
|
|
strategy: "substring",
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pass 6: Fuzzy match using similarity
|
|
let bestIndex: number | undefined;
|
|
let bestScore = 0;
|
|
let matchCount = 0;
|
|
const matchIndices: number[] = [];
|
|
|
|
for (let i = startFrom; i < lines.length; i++) {
|
|
const lineNorm = normalizeForFuzzy(lines[i]);
|
|
const score = similarity(lineNorm, contextNorm);
|
|
if (score >= CONTEXT_FUZZY_THRESHOLD) {
|
|
matchCount++;
|
|
if (matchIndices.length < 5) matchIndices.push(i);
|
|
}
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
|
|
if (bestIndex !== undefined && bestScore >= CONTEXT_FUZZY_THRESHOLD) {
|
|
return { index: bestIndex, confidence: bestScore, matchCount, matchIndices, strategy: "fuzzy" };
|
|
}
|
|
|
|
if (!options?.skipFunctionFallback && trimmedContext.endsWith("()")) {
|
|
const withParen = trimmedContext.replace(/\(\)\s*$/u, "(");
|
|
const withoutParen = trimmedContext.replace(/\(\)\s*$/u, "");
|
|
const parenResult = findContextLine(lines, withParen, startFrom, { allowFuzzy, skipFunctionFallback: true });
|
|
if (parenResult.index !== undefined || (parenResult.matchCount ?? 0) > 0) {
|
|
return parenResult;
|
|
}
|
|
return findContextLine(lines, withoutParen, startFrom, { allowFuzzy, skipFunctionFallback: true });
|
|
}
|
|
|
|
return { index: undefined, confidence: bestScore };
|
|
}
|