@theia/core
Version:
Theia is a cloud & desktop IDE framework implemented in TypeScript.
152 lines (134 loc) • 6.58 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
// *****************************************************************************
export interface Replacement {
oldContent: string;
newContent: string;
multiple?: boolean;
}
export class ContentReplacer {
/**
* Applies a list of replacements to the original content using a multi-step matching strategy.
* @param originalContent The original file content.
* @param replacements Array of Replacement objects.
* @param allowMultiple If true, all occurrences of each oldContent will be replaced. If false, an error is returned when multiple occurrences are found.
* @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;
}
let matchIndices = this.findExactMatches(updatedContent, oldContent);
if (matchIndices.length === 0) {
matchIndices = this.findLineTrimmedMatches(updatedContent, oldContent);
}
if (matchIndices.length === 0) {
errorMessages.push(`Content to replace not found: "${oldContent}"`);
} else if (matchIndices.length > 1) {
if (multiple) {
updatedContent = this.replaceContentAll(updatedContent, oldContent, newContent);
} else {
errorMessages.push(`Multiple occurrences found for: "${oldContent}"`);
}
} else {
updatedContent = this.replaceContentOnce(updatedContent, oldContent, newContent);
}
});
return { updatedContent, errors: errorMessages };
}
/**
* Finds all exact matches of a substring within a string.
* @param content The content to search within.
* @param search The substring to search for.
* @returns An array of starting indices where the exact substring is found.
*/
private findExactMatches(content: string, search: string): number[] {
const indices: number[] = [];
let startIndex = 0;
while ((startIndex = content.indexOf(search, startIndex)) !== -1) {
indices.push(startIndex);
startIndex += search.length;
}
return indices;
}
/**
* Attempts to find matches by trimming whitespace from lines in the original content and the search string.
* @param content The original content.
* @param search The substring to search for, potentially with varying whitespace.
* @returns An array of starting indices where a trimmed match is found.
*/
private findLineTrimmedMatches(content: string, search: string): number[] {
const trimmedSearch = search.trim();
const lines = content.split('\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);
return [startIndex];
}
}
return [];
}
/**
* Calculates the starting index of a specific line number in the content.
* @param content The original content.
* @param lineNumber The zero-based line number.
* @returns The starting index of the specified line.
*/
private getLineStartIndex(content: string, lineNumber: number): number {
const lines = content.split('\n');
let index = 0;
for (let i = 0; i < lineNumber; i++) {
index += lines[i].length + 1; // +1 for the newline character
}
return index;
}
/**
* Replaces the first occurrence of oldContent with newContent in the content.
* @param content The original content.
* @param oldContent The content to be replaced.
* @param newContent The content to replace with.
* @returns The content after replacement.
*/
private replaceContentOnce(content: string, oldContent: string, newContent: string): string {
const index = content.indexOf(oldContent);
if (index === -1) { return content; }
return content.substring(0, index) + newContent + content.substring(index + oldContent.length);
}
/**
* Replaces all occurrences of oldContent with newContent in the content.
* @param content The original content.
* @param oldContent The content to be replaced.
* @param newContent The content to replace with.
* @returns The content after all replacements.
*/
private replaceContentAll(content: string, oldContent: string, newContent: string): string {
return content.split(oldContent).join(newContent);
}
}