fix: Fix ignore case and multi-word fallback in replace-links

This commit is contained in:
Kodai Nakamura (aider) 2025-05-03 11:38:31 +09:00
parent 368818b935
commit e8945fa7f4

View file

@ -437,10 +437,11 @@ const processStandardText = (
// Use the explicit alias from the canonical path // Use the explicit alias from the canonical path
result += `[[${normalizedPath}|${alias}]]`; result += `[[${normalizedPath}|${alias}]]`;
} else if (normalizedPath.includes("/")) { } else if (normalizedPath.includes("/")) {
// Namespace exists, use the last part of the normalized path as display text // Namespace exists, no explicit alias.
// Tests expect the last part of the *path*, not the original text's potential multi-word form. // Use original matched text for display if ignoreCase is true, otherwise use the last part of the path.
const segments = normalizedPath.split("/"); const displayText = settings.ignoreCase
const displayText = segments[segments.length - 1]; ? originalMatchedText
: normalizedPath.split("/").pop() || originalMatchedText; // Fallback to original text if split fails
result += `[[${normalizedPath}|${displayText}]]`; result += `[[${normalizedPath}|${displayText}]]`;
} else { } else {
// No namespace, no explicit alias. Use the original matched text. // No namespace, no explicit alias. Use the original matched text.
@ -452,115 +453,127 @@ const processStandardText = (
} }
} }
// Fallback: if no candidate was found via the trie. // Fallback: if no candidate was found via the trie, try multi-word fallback lookup.
if (settings.namespaceResolution) { if (settings.namespaceResolution) {
const fallbackMatch = text.slice(i).match(FALLBACK_REGEX); let longestMatch: {
if (fallbackMatch) { word: string;
const word = settings.ignoreCase length: number;
? fallbackMatch[1].toLowerCase() key: string;
: fallbackMatch[1]; candidateList: Array<[string, CandidateData]>;
} | null = null;
// Skip date formats // Iterate through potential multi-word sequences starting from i
if ( for (let k = i; k < text.length; k++) {
settings.ignoreDateFormats && const potentialMatch = text.substring(i, k + 1);
DATE_FORMAT_REGEX.test(word)
) {
result += word;
i += word.length;
continue outer;
}
// Skip dates and month notes
if (/^\d{2}$/.test(word) && /\d{4}-\d{2}-$/.test(result)) {
result += text[i];
i++;
continue outer;
}
if (isMonthNote(word)) {
result += word;
i += word.length;
continue;
}
// Try fallback lookup
const searchWord = settings.ignoreCase const searchWord = settings.ignoreCase
? word.toLowerCase() ? potentialMatch.toLowerCase()
: word; : potentialMatch;
const candidateList = fallbackIndex.get(searchWord);
// Basic boundary check: next char should be a boundary if not end of text
const nextChar = text[k + 1];
if (!isWordBoundary(nextChar)) {
// If the potential match itself isn't in the index, continue extending
if (!fallbackIndex.has(searchWord)) {
continue;
}
// If it is in the index, but the next char isn't a boundary, it's not a valid match end here.
// But a shorter version might have been valid, so we don't break yet.
}
const candidateList = fallbackIndex.get(searchWord);
if (candidateList) { if (candidateList) {
// Filter candidates to comply with namespace restrictions // Check word boundary at the beginning
const filteredCandidates = candidateList.filter( const prevChar = text[i - 1];
([, data]) => if (!isWordBoundary(prevChar)) {
!( // If the start isn't a word boundary, this isn't a valid match.
data.restrictNamespace && // However, a shorter match starting later might be, so just continue the outer loop.
data.namespace !== currentNamespace // We break the inner loop (k) because extending this further won't help.
), break;
}
// Skip date formats
if (
settings.ignoreDateFormats &&
DATE_FORMAT_REGEX.test(potentialMatch)
) {
continue; // Try longer match
}
// Skip month notes
if (isMonthNote(potentialMatch)) {
continue; // Try longer match
}
// Found a potential candidate in the fallback index
longestMatch = {
word: potentialMatch,
length: potentialMatch.length,
key: searchWord,
candidateList: candidateList,
};
// Continue checking for even longer matches
} else if (longestMatch && k > i + longestMatch.length -1) {
// If we had a match but the current longer string doesn't match,
// stop extending for this starting position 'i'.
break;
}
}
// Process the longest valid match found
if (longestMatch) {
// Filter candidates based on namespace restrictions
const filteredCandidates = longestMatch.candidateList.filter(
([, data]) =>
!(
data.restrictNamespace &&
data.namespace !== currentNamespace
),
);
let bestCandidateData: CandidateData | null = null;
if (filteredCandidates.length === 1) {
bestCandidateData = filteredCandidates[0][1];
} else if (filteredCandidates.length > 1) {
const bestCandidateResult = findBestCandidateInSameNamespace(
filteredCandidates,
filePath,
settings,
);
if (bestCandidateResult) {
bestCandidateData = bestCandidateResult[1];
}
}
if (bestCandidateData) {
// Found a valid candidate through fallback
const { linkPath, alias, hasAlias } = extractLinkParts(
bestCandidateData.canonical,
);
const normalizedPath = normalizeCanonicalPath(
linkPath,
settings.baseDir,
); );
if (filteredCandidates.length === 1) { // Format the link
// Single match - straightforward case const originalMatchedWord = longestMatch.word; // Use the actual matched word
const candidateData = filteredCandidates[0][1]; if (hasAlias) {
const { linkPath, alias, hasAlias } = extractLinkParts( result += `[[${normalizedPath}|${alias}]]`;
candidateData.canonical, } else if (normalizedPath.includes("/")) {
); // Namespace exists, no explicit alias.
const normalizedPath = normalizeCanonicalPath( // Use original matched word for display if ignoreCase is true, otherwise use the last part of the path.
linkPath, const displayText = settings.ignoreCase
settings.baseDir, ? originalMatchedWord
); : normalizedPath.split("/").pop() || originalMatchedWord; // Fallback to original word if split fails
result += `[[${normalizedPath}|${displayText}]]`;
// Format the link } else {
const originalMatchedWord = text.substring(i, i + word.length); // No namespace, no explicit alias. Use the original matched word.
if (hasAlias) { result += `[[${originalMatchedWord}]]`;
result += `[[${normalizedPath}|${alias}]]`;
} else if (normalizedPath.includes("/")) {
// Namespace exists, use the last part of the normalized path as display text
const segments = normalizedPath.split("/");
const displayText = segments[segments.length - 1];
result += `[[${normalizedPath}|${displayText}]]`;
} else {
// No namespace, no explicit alias. Use the original matched word.
result += `[[${originalMatchedWord}]]`;
}
i += word.length;
continue outer;
} else if (filteredCandidates.length > 1) {
// Multiple matches - find the best candidate
const bestCandidate = findBestCandidateInSameNamespace(
filteredCandidates,
filePath,
settings,
);
if (bestCandidate) {
const candidateData = bestCandidate[1];
const { linkPath, alias, hasAlias } =
extractLinkParts(candidateData.canonical);
const normalizedPath = normalizeCanonicalPath(
linkPath,
settings.baseDir,
);
// Format the link
const originalMatchedWord = text.substring(i, i + word.length);
if (hasAlias) {
result += `[[${normalizedPath}|${alias}]]`;
} else if (normalizedPath.includes("/")) {
// Namespace exists, use the last part of the normalized path as display text
const segments = normalizedPath.split("/");
const displayText = segments[segments.length - 1];
result += `[[${normalizedPath}|${displayText}]]`;
} else {
// No namespace, no explicit alias. Use the original matched word.
result += `[[${originalMatchedWord}]]`;
}
i += word.length;
continue outer;
}
} }
i += longestMatch.length; // Advance index by the length of the matched word
continue outer; // Continue processing from the new index
} }
} }
} }