UNPKG

@theia/core

Version:

Theia is a cloud & desktop IDE framework implemented in TypeScript.

407 lines • 18.2 kB
"use strict"; // ***************************************************************************** // 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 // ***************************************************************************** Object.defineProperty(exports, "__esModule", { value: true }); exports.ContentReplacerV2Impl = void 0; class ContentReplacerV2Impl { /** * 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, replacements) { let updatedContent = originalContent; const errorMessages = []; // Guard against conflicting replacements: if the same oldContent appears with different newContent, return with an error. const conflictMap = new Map(); 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 */ normalizeLineEndings(text) { return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); } /** * Finds matches using multiple strategies with increasing flexibility */ findMatches(content, search) { // 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. */ findExactMatches(content, search) { const matches = []; 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 */ findNormalizedLineEndingMatches(content, search) { const normalizedContent = this.normalizeLineEndings(content); const normalizedSearch = this.normalizeLineEndings(search); const matches = []; 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 */ mapNormalizedPositionToOriginal(originalContent, normalizedPosition) { 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) */ findLineTrimmedMatches(content, search) { 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 */ findFuzzyMultilineMatches(content, search) { // 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 = []; // 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. */ getLineStartIndex(content, lineNumber) { 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). */ getLineEndIndex(content, lineNumber) { 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 */ replaceSingleMatch(content, match, newContent) { 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 */ replaceAllMatches(content, matches, newContent) { // 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 */ preserveIndentation(originalContent, newContent, lineEnding) { var _a, _b; 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 = ((_a = line.match(/^\s*/)) === null || _a === void 0 ? void 0 : _a[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 = ((_b = line.match(/^\s*/)) === null || _b === void 0 ? void 0 : _b[0]) || ''; break; } } // Apply the indentation to all lines of new content const result = newLines.map(line => { var _a; // Empty lines remain empty if (line.trim().length === 0) { return ''; } // Get current line's indentation const currentIndent = ((_a = line.match(/^\s*/)) === null || _a === void 0 ? void 0 : _a[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 */ convertLineEndings(content, lineEnding) { // 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 */ truncateForError(content, maxLength = 100) { 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); } } exports.ContentReplacerV2Impl = ContentReplacerV2Impl; //# sourceMappingURL=content-replacer-v2-impl.js.map