-
Notifications
You must be signed in to change notification settings - Fork 2.5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add search and replace function to coder (#14774)
fixed #14773 Signed-off-by: Jonas Helming <[email protected]>
- Loading branch information
1 parent
621a45a
commit 32f58e6
Showing
6 changed files
with
338 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
92 changes: 92 additions & 0 deletions
92
packages/ai-workspace-agent/src/browser/content-replacer.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
// ***************************************************************************** | ||
// 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 { expect } from 'chai'; | ||
import { ContentReplacer, Replacement } from './content-replacer'; | ||
|
||
describe('ContentReplacer', () => { | ||
let contentReplacer: ContentReplacer; | ||
|
||
before(() => { | ||
contentReplacer = new ContentReplacer(); | ||
}); | ||
|
||
it('should replace content when oldContent matches exactly', () => { | ||
const originalContent = 'Hello World!'; | ||
const replacements: Replacement[] = [ | ||
{ oldContent: 'World', newContent: 'Universe' } | ||
]; | ||
const expectedContent = 'Hello Universe!'; | ||
const result = contentReplacer.applyReplacements(originalContent, replacements); | ||
expect(result.updatedContent).to.equal(expectedContent); | ||
expect(result.errors).to.be.empty; | ||
}); | ||
|
||
it('should replace content when oldContent matches after trimming lines', () => { | ||
const originalContent = 'Line one\n Line two \nLine three'; | ||
const replacements: Replacement[] = [ | ||
{ oldContent: 'Line two', newContent: 'Second Line' } | ||
]; | ||
const expectedContent = 'Line one\n Second Line \nLine three'; | ||
const result = contentReplacer.applyReplacements(originalContent, replacements); | ||
expect(result.updatedContent).to.equal(expectedContent); | ||
expect(result.errors).to.be.empty; | ||
}); | ||
|
||
it('should return an error when oldContent is not found', () => { | ||
const originalContent = 'Sample content'; | ||
const replacements: Replacement[] = [ | ||
{ oldContent: 'Nonexistent', newContent: 'Replacement' } | ||
]; | ||
const result = contentReplacer.applyReplacements(originalContent, replacements); | ||
expect(result.updatedContent).to.equal(originalContent); | ||
expect(result.errors).to.include('Content to replace not found: "Nonexistent"'); | ||
}); | ||
|
||
it('should return an error when oldContent has multiple occurrences', () => { | ||
const originalContent = 'Repeat Repeat Repeat'; | ||
const replacements: Replacement[] = [ | ||
{ oldContent: 'Repeat', newContent: 'Once' } | ||
]; | ||
const result = contentReplacer.applyReplacements(originalContent, replacements); | ||
expect(result.updatedContent).to.equal(originalContent); | ||
expect(result.errors).to.include('Multiple occurrences found for: "Repeat"'); | ||
}); | ||
|
||
it('should prepend newContent when oldContent is an empty string', () => { | ||
const originalContent = 'Existing content'; | ||
const replacements: Replacement[] = [ | ||
{ oldContent: '', newContent: 'Prepended content\n' } | ||
]; | ||
const expectedContent = 'Prepended content\nExisting content'; | ||
const result = contentReplacer.applyReplacements(originalContent, replacements); | ||
expect(result.updatedContent).to.equal(expectedContent); | ||
expect(result.errors).to.be.empty; | ||
}); | ||
|
||
it('should handle multiple replacements correctly', () => { | ||
const originalContent = 'Foo Bar Baz'; | ||
const replacements: Replacement[] = [ | ||
{ oldContent: 'Foo', newContent: 'FooModified' }, | ||
{ oldContent: 'Bar', newContent: 'BarModified' }, | ||
{ oldContent: 'Baz', newContent: 'BazModified' } | ||
]; | ||
const expectedContent = 'FooModified BarModified BazModified'; | ||
const result = contentReplacer.applyReplacements(originalContent, replacements); | ||
expect(result.updatedContent).to.equal(expectedContent); | ||
expect(result.errors).to.be.empty; | ||
}); | ||
}); |
126 changes: 126 additions & 0 deletions
126
packages/ai-workspace-agent/src/browser/content-replacer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,126 @@ | ||
// ***************************************************************************** | ||
// 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; | ||
} | ||
|
||
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. | ||
* @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[] = []; | ||
|
||
replacements.forEach(({ oldContent, newContent }) => { | ||
// 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) { | ||
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); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.