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:
2026-05-31 21:41:09 +02:00
co-authored by Claude Opus 4.8
parent d26a1f895c
commit 4aed6c12a5
11 changed files with 821 additions and 8 deletions
@@ -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');
});
}
+30
View File
@@ -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);
});
}
+136
View File
@@ -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');
});
});
}