add search-and-replace across files
Builds on the find-in-files engine. A replace engine applies the query's replacement to each matching file — literal or regex with capture-group expansion ($1, $&, $$) — and reports per-file, per-line before/after edits computed with the same logic the apply uses, so preview and apply never disagree. The search.replace command previews (no disk writes) or applies (writing each changed file through the workspace path-safety guard). The panel gains a Replace field: each match row previews its rewritten line, and Replace all is gated on a clean git working tree (git is the undo) plus a confirmation before it writes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -173,4 +173,44 @@ void main() {
|
||||
await c.run('foo');
|
||||
expect(sent!['exclude'], ['build/**', '*.g.dart']);
|
||||
});
|
||||
|
||||
test('setReplacement updates the field and notifies', () {
|
||||
final c = make();
|
||||
var n = 0;
|
||||
c.addListener(() => n++);
|
||||
c.setReplacement('baz');
|
||||
expect(c.replacement, 'baz');
|
||||
expect(n, 1);
|
||||
c.setReplacement('baz'); // no change
|
||||
expect(n, 1);
|
||||
});
|
||||
|
||||
test('isWorkingTreeClean reflects git.status clean flag', () async {
|
||||
f.ipc.stub('git.status', (_) async => _ok(const {'clean': true}));
|
||||
expect(await make().isWorkingTreeClean(), isTrue);
|
||||
f.ipc.stub('git.status', (_) async => _ok(const {'clean': false}));
|
||||
expect(await make().isWorkingTreeClean(), isFalse);
|
||||
});
|
||||
|
||||
test('applyReplace sends apply, returns the summary, and refreshes', () async {
|
||||
Map<String, Object?>? sent;
|
||||
var grepCalls = 0;
|
||||
f.ipc.stub('search.replace', (args) async {
|
||||
sent = args;
|
||||
return _ok(const {'apply': true, 'filesChanged': 3, 'totalCount': 7});
|
||||
});
|
||||
f.ipc.stub('search.grep', (_) async {
|
||||
grepCalls++;
|
||||
return _ok({'searchId': 's1'});
|
||||
});
|
||||
final c = make();
|
||||
c.setReplacement('baz');
|
||||
await c.run('foo'); // grepCalls == 1
|
||||
final res = await c.applyReplace();
|
||||
expect(sent!['apply'], isTrue);
|
||||
expect(sent!['replacement'], 'baz');
|
||||
expect(res.files, 3);
|
||||
expect(res.count, 7);
|
||||
expect(grepCalls, 2); // applyReplace re-runs the search
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import '../../helpers/widget_harness.dart';
|
||||
|
||||
IpcResponse _ok(Map<String, Object?> data) => IpcResponse.ok(id: '', data: data);
|
||||
|
||||
/// SearchPanelView wrapped in a DialogHost so the replace confirm /
|
||||
/// not-clean dialogs render and can be driven.
|
||||
Widget _withDialogs(KernelFixture f) => DialogHost(router: f.services.dialog, child: const SearchPanelView());
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
|
||||
@@ -115,4 +119,60 @@ void main() {
|
||||
await pumpAsync(tester);
|
||||
expect(grepCalls, greaterThan(before));
|
||||
});
|
||||
|
||||
// Drive a search so there are matches + set a replacement string.
|
||||
Future<void> seedReplace(WidgetTester tester) async {
|
||||
await tester.enterText(find.byType(EditableText).first, 'foo');
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
await pumpAsync(tester);
|
||||
emitMatches();
|
||||
await pumpAsync(tester);
|
||||
await tester.enterText(find.byType(EditableText).at(1), 'bar'); // replace field
|
||||
await pumpAsync(tester);
|
||||
}
|
||||
|
||||
testWidgets('replace preview renders the rewritten line', (tester) async {
|
||||
await tester.pumpWidget(harness(f, _withDialogs(f)));
|
||||
await seedReplace(tester);
|
||||
// The emitted match line is 'final foo = 1;' → preview shows the after
|
||||
// (rendered as a RichText span, so match on the plain text).
|
||||
expect(
|
||||
find.byWidgetPredicate((w) => w is RichText && w.text.toPlainText() == 'final bar = 1;'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('Replace all on a dirty tree shows a guard dialog, no apply', (tester) async {
|
||||
var applyCalled = false;
|
||||
f.ipc.stub('git.status', (_) async => _ok(const {'clean': false}));
|
||||
f.ipc.stub('search.replace', (_) async {
|
||||
applyCalled = true;
|
||||
return _ok(const {'apply': true, 'filesChanged': 0, 'totalCount': 0});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, _withDialogs(f)));
|
||||
await seedReplace(tester);
|
||||
await tester.tap(find.text('Replace all'));
|
||||
await pumpAsync(tester);
|
||||
expect(find.text('Working tree not clean'), findsOneWidget);
|
||||
expect(applyCalled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Replace all on a clean tree confirms then applies', (tester) async {
|
||||
Map<String, Object?>? applyArgs;
|
||||
f.ipc.stub('git.status', (_) async => _ok(const {'clean': true}));
|
||||
f.ipc.stub('search.replace', (args) async {
|
||||
applyArgs = args;
|
||||
return _ok(const {'apply': true, 'filesChanged': 1, 'totalCount': 1});
|
||||
});
|
||||
await tester.pumpWidget(harness(f, _withDialogs(f)));
|
||||
await seedReplace(tester);
|
||||
await tester.tap(find.text('Replace all'));
|
||||
await pumpAsync(tester);
|
||||
// Confirm dialog up; confirm it.
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await pumpAsync(tester);
|
||||
expect(applyArgs, isNotNull);
|
||||
expect(applyArgs!['apply'], isTrue);
|
||||
expect(applyArgs!['replacement'], 'bar');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,4 +82,34 @@ void main() {
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['cancelled'], 'search-0');
|
||||
});
|
||||
|
||||
test('search.replace preview reports edits without touching disk', () async {
|
||||
final r = await call('search.replace', const {'pattern': 'answer', 'replacement': 'result'});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['apply'], isFalse);
|
||||
expect(r.data['fileCount'], 1);
|
||||
expect(r.data['totalCount'], 1);
|
||||
// File is untouched.
|
||||
expect(File('${dir.path}/a.dart').readAsStringSync(), 'final answer = 42;\n');
|
||||
});
|
||||
|
||||
test('search.replace apply rewrites the matching files', () async {
|
||||
final r = await call('search.replace', const {'pattern': 'answer', 'replacement': 'result', 'apply': true});
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['apply'], isTrue);
|
||||
expect(r.data['filesChanged'], 1);
|
||||
expect(File('${dir.path}/a.dart').readAsStringSync(), 'final result = 42;\n');
|
||||
});
|
||||
|
||||
test('search.replace with an empty pattern is a userError', () async {
|
||||
final r = await call('search.replace', const {'pattern': '', 'replacement': 'x'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, IpcErrorKind.userError);
|
||||
});
|
||||
|
||||
test('search.replace with an invalid regex is a userError', () async {
|
||||
final r = await call('search.replace', const {'pattern': '(bad', 'regex': true, 'replacement': 'x'});
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, IpcErrorKind.userError);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/// Tests for the search-and-replace engine (T-53, per D-79).
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/files/ignore.dart';
|
||||
import 'package:clide/src/search/match.dart';
|
||||
import 'package:clide/src/search/replace_engine.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('applyToText', () {
|
||||
test('literal replacement counts and substitutes', () {
|
||||
final r = applyToText('foo foo bar', const SearchQuery(pattern: 'foo'), 'X');
|
||||
expect(r.text, 'X X bar');
|
||||
expect(r.count, 2);
|
||||
});
|
||||
|
||||
test('literal is case-sensitive by default, case-insensitive on request', () {
|
||||
expect(applyToText('Foo foo', const SearchQuery(pattern: 'foo'), 'X').count, 1);
|
||||
expect(applyToText('Foo foo', const SearchQuery(pattern: 'foo', ignoreCase: true), 'X').count, 2);
|
||||
});
|
||||
|
||||
test(r'literal replacement does not expand $ references', () {
|
||||
final r = applyToText('foo', const SearchQuery(pattern: 'foo'), r'$1-lit');
|
||||
expect(r.text, r'$1-lit');
|
||||
});
|
||||
|
||||
test('regex replacement expands capture groups', () {
|
||||
final r = applyToText('alpha beta', const SearchQuery(pattern: r'(\w+) (\w+)', regex: true), r'$2 $1');
|
||||
expect(r.text, 'beta alpha');
|
||||
expect(r.count, 1);
|
||||
});
|
||||
|
||||
test(r'regex $& is the whole match and $$ is a literal dollar', () {
|
||||
final r = applyToText('x=1', const SearchQuery(pattern: r'\d', regex: true), r'$$$&');
|
||||
expect(r.text, r'x=$1');
|
||||
});
|
||||
|
||||
test('no match leaves text unchanged with count 0', () {
|
||||
final r = applyToText('abc', const SearchQuery(pattern: 'zzz'), 'X');
|
||||
expect(r.text, 'abc');
|
||||
expect(r.count, 0);
|
||||
});
|
||||
|
||||
test('empty pattern is a no-op', () {
|
||||
final r = applyToText('abc', const SearchQuery(pattern: ''), 'X');
|
||||
expect(r.count, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('computeReplacements', () {
|
||||
late Directory root;
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('clide-replace-');
|
||||
File('${root.path}/a.dart').writeAsStringSync('final foo = 1;\nfinal bar = foo;\n');
|
||||
File('${root.path}/b.txt').writeAsStringSync('no hits\n');
|
||||
});
|
||||
tearDown(() async => root.delete(recursive: true));
|
||||
|
||||
test('reports changed files with per-line before/after edits', () async {
|
||||
final r = await computeReplacements(
|
||||
root: root,
|
||||
ignore: IgnoreSet([]),
|
||||
query: const SearchQuery(pattern: 'foo'),
|
||||
replacement: 'baz',
|
||||
);
|
||||
expect(r, hasLength(1));
|
||||
final fr = r.single;
|
||||
expect(fr.path, 'a.dart');
|
||||
expect(fr.count, 2);
|
||||
expect(fr.edits, hasLength(2));
|
||||
expect(fr.edits.first.before, 'final foo = 1;');
|
||||
expect(fr.edits.first.after, 'final baz = 1;');
|
||||
});
|
||||
|
||||
test('files with no match are omitted', () async {
|
||||
final r = await computeReplacements(
|
||||
root: root,
|
||||
ignore: IgnoreSet([]),
|
||||
query: const SearchQuery(pattern: 'foo'),
|
||||
replacement: 'baz',
|
||||
);
|
||||
expect(r.any((f) => f.path == 'b.txt'), isFalse);
|
||||
});
|
||||
|
||||
test('honours the ignore set', () async {
|
||||
final r = await computeReplacements(
|
||||
root: root,
|
||||
ignore: IgnoreSet.parse(const ['*.dart\n']),
|
||||
query: const SearchQuery(pattern: 'foo'),
|
||||
replacement: 'baz',
|
||||
);
|
||||
expect(r, isEmpty);
|
||||
});
|
||||
|
||||
test('binary files are skipped', () async {
|
||||
File('${root.path}/blob.bin').writeAsBytesSync([0x66, 0x6f, 0x6f, 0x00, 0x66, 0x6f, 0x6f]);
|
||||
final r = await computeReplacements(
|
||||
root: root,
|
||||
ignore: IgnoreSet([]),
|
||||
query: const SearchQuery(pattern: 'foo'),
|
||||
replacement: 'baz',
|
||||
);
|
||||
expect(r.any((f) => f.path == 'blob.bin'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('rewriteFileContent', () {
|
||||
late Directory root;
|
||||
setUp(() async {
|
||||
root = await Directory.systemTemp.createTemp('clide-rewrite-');
|
||||
File('${root.path}/a.dart').writeAsStringSync('foo and foo\n');
|
||||
});
|
||||
tearDown(() async => root.delete(recursive: true));
|
||||
|
||||
test('returns the rewritten content', () {
|
||||
final out = rewriteFileContent(root.absolute.path, 'a.dart', const SearchQuery(pattern: 'foo'), 'X');
|
||||
expect(out, 'X and X\n');
|
||||
});
|
||||
|
||||
test('returns null when nothing changes', () {
|
||||
final out = rewriteFileContent(root.absolute.path, 'a.dart', const SearchQuery(pattern: 'zzz'), 'X');
|
||||
expect(out, isNull);
|
||||
});
|
||||
|
||||
test('ReplacementEdit + FileReplacement round-trip JSON', () {
|
||||
const fr = FileReplacement(path: 'a.dart', count: 1, edits: [ReplacementEdit(line: 2, before: 'a', after: 'b')]);
|
||||
final back = FileReplacement.fromJson(fr.toJson());
|
||||
expect(back.path, 'a.dart');
|
||||
expect(back.count, 1);
|
||||
expect(back.edits.single.line, 2);
|
||||
expect(back.edits.single.after, 'b');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user