@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
472 lines (400 loc) • 18.7 kB
text/typescript
// *****************************************************************************
// Copyright (C) 2025 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import { ContentReplacer, Replacement } from './content-replacer';
/**
* Represents a match with its position and the actual matched content
*/
interface MatchInfo {
startIndex: number;
endIndex: number;
matchedContent: string;
}
/**
* Result of finding matches
*/
interface MatchResult {
matches: MatchInfo[];
strategy: string;
}
export class ContentReplacerV2Impl implements ContentReplacer {
/**
* Applies a list of replacements to the original content using a multi-step matching strategy with improved flexibility.
* @param originalContent The original file content.
* @param replacements Array of Replacement objects.
* @returns An object containing the updated content and any error messages.
*/
applyReplacements(originalContent: string, replacements: Replacement[]): { updatedContent: string, errors: string[] } {
let updatedContent = originalContent;
const errorMessages: string[] = [];
// Guard against conflicting replacements: if the same oldContent appears with different newContent, return with an error.
const conflictMap = new Map<string, string>();
for (const replacement of replacements) {
if (conflictMap.has(replacement.oldContent) && conflictMap.get(replacement.oldContent) !== replacement.newContent) {
return { updatedContent: originalContent, errors: [`Conflicting replacement values for: "${replacement.oldContent}"`] };
}
conflictMap.set(replacement.oldContent, replacement.newContent);
}
replacements.forEach(({ oldContent, newContent, multiple }) => {
// If the old content is empty, prepend the new content to the beginning of the file (e.g. in new file)
if (oldContent === '') {
updatedContent = newContent + updatedContent;
return;
}
// Try multiple matching strategies
const matchResult = this.findMatches(updatedContent, oldContent);
if (matchResult.matches.length === 0) {
const truncatedOld = this.truncateForError(oldContent);
errorMessages.push(`Content to replace not found: "${truncatedOld}"`);
} else if (matchResult.matches.length > 1) {
if (multiple) {
updatedContent = this.replaceAllMatches(updatedContent, matchResult.matches, newContent);
} else {
const truncatedOld = this.truncateForError(oldContent);
errorMessages.push(`Multiple occurrences found for: "${truncatedOld}". Set 'multiple' to true if multiple occurrences of the oldContent are expected to be\
replaced at once.`);
}
} else {
updatedContent = this.replaceSingleMatch(updatedContent, matchResult.matches[0], newContent);
}
});
return { updatedContent, errors: errorMessages };
}
/**
* Normalizes line endings to LF
*/
private normalizeLineEndings(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}
/**
* Finds matches using multiple strategies with increasing flexibility
*/
private findMatches(content: string, search: string): MatchResult {
// Strategy 1: Exact match
const exactMatches = this.findExactMatches(content, search);
if (exactMatches.length > 0) {
return { matches: exactMatches, strategy: 'exact' };
}
// Strategy 2: Match with normalized line endings
const normalizedMatches = this.findNormalizedLineEndingMatches(content, search);
if (normalizedMatches.length > 0) {
return { matches: normalizedMatches, strategy: 'normalized-line-endings' };
}
// Strategy 3: Single line trimmed match (for backward compatibility)
const lineTrimmedMatches = this.findLineTrimmedMatches(content, search);
if (lineTrimmedMatches.length > 0) {
return { matches: lineTrimmedMatches, strategy: 'line-trimmed' };
}
// Strategy 4: Multi-line fuzzy match with trimmed comparison
const fuzzyMatches = this.findFuzzyMultilineMatches(content, search);
if (fuzzyMatches.length > 0) {
return { matches: fuzzyMatches, strategy: 'fuzzy-multiline' };
}
return { matches: [], strategy: 'none' };
}
/**
* Finds all exact matches of a substring within a string.
*/
private findExactMatches(content: string, search: string): MatchInfo[] {
const matches: MatchInfo[] = [];
let startIndex = 0;
while ((startIndex = content.indexOf(search, startIndex)) !== -1) {
matches.push({
startIndex,
endIndex: startIndex + search.length,
matchedContent: search
});
startIndex += search.length;
}
return matches;
}
/**
* Finds matches after normalizing line endings
*/
private findNormalizedLineEndingMatches(content: string, search: string): MatchInfo[] {
const normalizedContent = this.normalizeLineEndings(content);
const normalizedSearch = this.normalizeLineEndings(search);
const matches: MatchInfo[] = [];
let startIndex = 0;
while ((startIndex = normalizedContent.indexOf(normalizedSearch, startIndex)) !== -1) {
// Map back to original content position
const originalStartIndex = this.mapNormalizedPositionToOriginal(content, startIndex);
const originalEndIndex = this.mapNormalizedPositionToOriginal(content, startIndex + normalizedSearch.length);
matches.push({
startIndex: originalStartIndex,
endIndex: originalEndIndex,
matchedContent: content.substring(originalStartIndex, originalEndIndex)
});
startIndex += normalizedSearch.length;
}
return matches;
}
/**
* Maps a position in normalized content back to the original content
*/
private mapNormalizedPositionToOriginal(originalContent: string, normalizedPosition: number): number {
let originalPos = 0;
let normalizedPos = 0;
while (normalizedPos < normalizedPosition && originalPos < originalContent.length) {
if (originalPos + 1 < originalContent.length &&
originalContent[originalPos] === '\r' &&
originalContent[originalPos + 1] === '\n') {
// CRLF in original maps to single LF in normalized
originalPos += 2;
normalizedPos += 1;
} else if (originalContent[originalPos] === '\r') {
// Single CR in original maps to LF in normalized
originalPos += 1;
normalizedPos += 1;
} else {
// All other characters map 1:1
originalPos += 1;
normalizedPos += 1;
}
}
return originalPos;
}
/**
* Attempts to find matches by trimming whitespace from lines (single line only, for backward compatibility)
*/
private findLineTrimmedMatches(content: string, search: string): MatchInfo[] {
const trimmedSearch = search.trim();
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
const trimmedLine = lines[i].trim();
if (trimmedLine === trimmedSearch) {
// Calculate the starting index of this line in the original content
const startIndex = this.getLineStartIndex(content, i);
const endIndex = startIndex + lines[i].length;
return [{
startIndex,
endIndex,
matchedContent: lines[i]
}];
}
}
return [];
}
/**
* Finds matches using fuzzy multi-line comparison with trimmed lines
*/
private findFuzzyMultilineMatches(content: string, search: string): MatchInfo[] {
// Extract non-empty lines from search for matching
const searchLines = search.split(/\r?\n/);
const nonEmptySearchLines = searchLines
.map(line => line.trim())
.filter(line => line.length > 0);
if (nonEmptySearchLines.length === 0) { return []; }
const contentLines = content.split(/\r?\n/);
const matches: MatchInfo[] = [];
// Try to find sequences in content that match all non-empty lines from search
for (let contentStart = 0; contentStart < contentLines.length; contentStart++) {
// First, check if this could be a valid starting position
const startLineTrimmed = contentLines[contentStart].trim();
if (startLineTrimmed.length === 0 || startLineTrimmed !== nonEmptySearchLines[0]) {
continue;
}
let searchIndex = 1; // We already matched the first line
let contentIndex = contentStart + 1;
let lastMatchedLine = contentStart;
// Try to match remaining non-empty lines from search
while (searchIndex < nonEmptySearchLines.length && contentIndex < contentLines.length) {
const contentLineTrimmed = contentLines[contentIndex].trim();
if (contentLineTrimmed.length === 0) {
// Skip empty lines in content
contentIndex++;
} else if (contentLineTrimmed === nonEmptySearchLines[searchIndex]) {
// Found a match
lastMatchedLine = contentIndex;
searchIndex++;
contentIndex++;
} else {
// No match, this starting position doesn't work
break;
}
}
// Check if we matched all non-empty lines
if (searchIndex === nonEmptySearchLines.length) {
const startIndex = this.getLineStartIndex(content, contentStart);
const endIndex = this.getLineEndIndex(content, lastMatchedLine);
matches.push({
startIndex,
endIndex,
matchedContent: content.substring(startIndex, endIndex)
});
}
}
return matches;
}
/**
* Calculates the starting index of a specific line number in the content.
*/
private getLineStartIndex(content: string, lineNumber: number): number {
if (lineNumber === 0) { return 0; }
let index = 0;
let currentLine = 0;
while (currentLine < lineNumber && index < content.length) {
if (content[index] === '\r' && index + 1 < content.length && content[index + 1] === '\n') {
index += 2; // CRLF
currentLine++;
} else if (content[index] === '\r' || content[index] === '\n') {
index += 1; // CR or LF
currentLine++;
} else {
index += 1;
}
}
return index;
}
/**
* Calculates the ending index of a specific line number in the content (including the line).
*/
private getLineEndIndex(content: string, lineNumber: number): number {
const lines = content.split(/\r?\n/);
if (lineNumber >= lines.length) {
return content.length;
}
let index = 0;
for (let i = 0; i <= lineNumber; i++) {
index += lines[i].length;
if (i < lineNumber) {
// Add line ending length
const searchPos = index;
if (content.indexOf('\r\n', searchPos) === searchPos) {
index += 2; // CRLF
} else if (index < content.length && (content[index] === '\r' || content[index] === '\n')) {
index += 1; // CR or LF
}
}
}
return index;
}
/**
* Replaces a single match while preserving indentation
*/
private replaceSingleMatch(content: string, match: MatchInfo, newContent: string): string {
const beforeMatch = content.substring(0, match.startIndex);
const afterMatch = content.substring(match.endIndex);
// Detect the line ending style from entire original content, not just the match
const originalLineEnding = content.includes('\r\n') ? '\r\n' :
content.includes('\r') ? '\r' : '\n';
// Convert line endings in newContent to match original
const newContentWithCorrectLineEndings = this.convertLineEndings(newContent, originalLineEnding);
// Preserve indentation from the matched content
const preservedReplacement = this.preserveIndentation(match.matchedContent, newContentWithCorrectLineEndings, originalLineEnding);
return beforeMatch + preservedReplacement + afterMatch;
}
/**
* Replaces all matches
*/
private replaceAllMatches(content: string, matches: MatchInfo[], newContent: string): string {
// Sort matches by position (descending) to avoid position shifts
const sortedMatches = [...matches].sort((a, b) => b.startIndex - a.startIndex);
// Detect the line ending style from entire original content
const originalLineEnding = content.includes('\r\n') ? '\r\n' :
content.includes('\r') ? '\r' : '\n';
let result = content;
for (const match of sortedMatches) {
const beforeMatch = result.substring(0, match.startIndex);
const afterMatch = result.substring(match.endIndex);
// Convert line endings in newContent to match original
const newContentWithCorrectLineEndings = this.convertLineEndings(newContent, originalLineEnding);
const preservedReplacement = this.preserveIndentation(match.matchedContent, newContentWithCorrectLineEndings, originalLineEnding);
result = beforeMatch + preservedReplacement + afterMatch;
}
return result;
}
/**
* Preserves the indentation from the original content when applying the replacement
*/
private preserveIndentation(originalContent: string, newContent: string, lineEnding: string): string {
const originalLines = originalContent.split(/\r?\n/);
const newLines = newContent.split(/\r?\n/);
if (originalLines.length === 0 || newLines.length === 0) {
return newContent;
}
// Find first non-empty line in original to get base indentation
let originalBaseIndent = '';
let originalUseTabs = false;
for (const line of originalLines) {
if (line.trim().length > 0) {
originalBaseIndent = line.match(/^\s*/)?.[0] || '';
originalUseTabs = originalBaseIndent.includes('\t');
break;
}
}
// Find first non-empty line in new content to get base indentation
let newBaseIndent = '';
for (const line of newLines) {
if (line.trim().length > 0) {
newBaseIndent = line.match(/^\s*/)?.[0] || '';
break;
}
}
// Apply the indentation to all lines of new content
const result = newLines.map(line => {
// Empty lines remain empty
if (line.trim().length === 0) {
return '';
}
// Get current line's indentation
const currentIndent = line.match(/^\s*/)?.[0] || '';
// Calculate relative indentation
let relativeIndent = currentIndent;
if (newBaseIndent.length > 0) {
// If the current line has at least the base indentation, preserve relative indentation
if (currentIndent.startsWith(newBaseIndent)) {
relativeIndent = currentIndent.substring(newBaseIndent.length);
} else {
// If current line has less indentation than base, use it as-is
relativeIndent = '';
}
}
// Convert spaces to tabs if original uses tabs
let convertedIndent = originalBaseIndent + relativeIndent;
if (originalUseTabs && !relativeIndent.includes('\t')) {
// Convert 4 spaces to 1 tab (common convention)
convertedIndent = convertedIndent.replace(/ /g, '\t');
}
// Apply converted indentation + trimmed content
return convertedIndent + line.trim();
});
return result.join(lineEnding);
}
/**
* Converts line endings in content to the specified line ending style
*/
private convertLineEndings(content: string, lineEnding: string): string {
// First normalize to LF
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Then convert to target line ending
if (lineEnding === '\r\n') {
return normalized.replace(/\n/g, '\r\n');
} else if (lineEnding === '\r') {
return normalized.replace(/\n/g, '\r');
}
return normalized;
}
/**
* Truncates content for error messages to avoid overly long error messages
*/
private truncateForError(content: string, maxLength: number = 100): string {
if (content.length <= maxLength) {
return content;
}
const half = Math.floor(maxLength / 2) - 3; // -3 for "..."
return content.substring(0, half) + '...' + content.substring(content.length - half);
}
}