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
+187
View File
@@ -0,0 +1,187 @@
/// Workspace search-and-replace engine (T-53, per D-79).
///
/// Builds on the same query semantics as the grep engine: it walks the
/// ignore-pruned workspace, applies the replacement to each matching
/// file, and reports per-file, per-line before/after edits for preview.
/// The authoritative new file content is produced by applying the
/// replacement to the whole file; the per-line edits are derived with
/// the same logic so preview and apply never disagree.
///
/// Regex replacements support capture-group references (`$1`..`$9`,
/// `$&`/`$0` for the whole match, `$$` for a literal `$`).
library;
import 'dart:convert';
import 'dart:io';
import '../files/ignore.dart';
import '../files/listing.dart';
import 'match.dart';
/// One changed line within a file.
class ReplacementEdit {
const ReplacementEdit({required this.line, required this.before, required this.after});
final int line; // 1-based
final String before;
final String after;
Map<String, Object?> toJson() => {'line': line, 'before': before, 'after': after};
factory ReplacementEdit.fromJson(Map<String, Object?> j) => ReplacementEdit(
line: (j['line'] as num).toInt(),
before: j['before'] as String,
after: j['after'] as String,
);
}
/// The set of edits a replacement would make to one file.
class FileReplacement {
const FileReplacement({required this.path, required this.count, required this.edits});
final String path;
/// Number of individual matches replaced in the file.
final int count;
final List<ReplacementEdit> edits;
Map<String, Object?> toJson() => {
'path': path,
'count': count,
'edits': [for (final e in edits) e.toJson()],
};
factory FileReplacement.fromJson(Map<String, Object?> j) => FileReplacement(
path: j['path'] as String,
count: (j['count'] as num).toInt(),
edits: [
for (final e in (j['edits'] as List? ?? const []).whereType<Map>()) ReplacementEdit.fromJson(e.cast<String, Object?>()),
],
);
}
/// Apply [query]'s pattern to [text], substituting [replacement]. Returns
/// the rewritten text and the number of matches replaced.
({String text, int count}) applyToText(String text, SearchQuery query, String replacement) {
if (query.pattern.isEmpty) return (text: text, count: 0);
var count = 0;
final RegExp re;
if (query.regex) {
re = RegExp(query.pattern, caseSensitive: !query.ignoreCase);
} else {
re = RegExp(RegExp.escape(query.pattern), caseSensitive: !query.ignoreCase);
}
final out = text.replaceAllMapped(re, (m) {
count++;
return query.regex ? _expand(replacement, m) : replacement;
});
return (text: out, count: count);
}
/// Expand `$n` / `$&` / `$$` references in a regex replacement template.
String _expand(String template, Match m) {
final b = StringBuffer();
var i = 0;
while (i < template.length) {
final c = template[i];
if (c == r'$' && i + 1 < template.length) {
final next = template[i + 1];
if (next == r'$') {
b.write(r'$');
i += 2;
continue;
}
if (next == '&' || next == '0') {
b.write(m.group(0) ?? '');
i += 2;
continue;
}
if (_isDigit(next)) {
// Greedy two-digit group index when valid, else one digit.
var idx = int.parse(next);
var consumed = 2;
if (i + 2 < template.length && _isDigit(template[i + 2])) {
final two = int.parse('$next${template[i + 2]}');
if (two <= m.groupCount) {
idx = two;
consumed = 3;
}
}
if (idx <= m.groupCount) {
b.write(m.group(idx) ?? '');
i += consumed;
continue;
}
}
}
b.write(c);
i++;
}
return b.toString();
}
bool _isDigit(String s) => s.codeUnitAt(0) >= 0x30 && s.codeUnitAt(0) <= 0x39;
/// Compute the replacements [query] → [replacement] would make under
/// [root]. Returns one [FileReplacement] per changed file, with
/// per-line before/after edits for preview. Pure read-only — writing is
/// the caller's job (after the clean-tree safety gate).
Future<List<FileReplacement>> computeReplacements({
required Directory root,
required IgnoreSet ignore,
required SearchQuery query,
required String replacement,
int maxFiles = 5000,
}) async {
if (query.pattern.isEmpty) return const [];
if (query.regex) RegExp(query.pattern, caseSensitive: !query.ignoreCase); // validate
final walk = await walkFiles(root: root, ignore: ignore);
final rootPath = root.absolute.path;
final out = <FileReplacement>[];
for (final entry in walk.files) {
if (out.length >= maxFiles) break;
final fr = _replaceInFile(rootPath, entry.path, query, replacement);
if (fr != null) out.add(fr);
}
return out;
}
/// Compute the rewritten content for a single file, or null if the file
/// is binary/unreadable/unchanged. Exposed for the apply path + tests.
String? rewriteFileContent(String rootPath, String relPath, SearchQuery query, String replacement) {
final content = _readText('$rootPath/$relPath');
if (content == null) return null;
final r = applyToText(content, query, replacement);
if (r.count == 0) return null;
return r.text;
}
FileReplacement? _replaceInFile(String rootPath, String relPath, SearchQuery query, String replacement) {
final content = _readText('$rootPath/$relPath');
if (content == null) return null;
final whole = applyToText(content, query, replacement);
if (whole.count == 0) return null;
// Per-line preview edits, using the same apply logic line-by-line.
final edits = <ReplacementEdit>[];
var lineNo = 0;
for (final line in const LineSplitter().convert(content)) {
lineNo++;
final r = applyToText(line, query, replacement);
if (r.count > 0 && r.text != line) {
edits.add(ReplacementEdit(line: lineNo, before: line, after: r.text));
}
}
return FileReplacement(path: relPath, count: whole.count, edits: edits);
}
String? _readText(String absPath) {
try {
final bytes = File(absPath).readAsBytesSync();
final probe = bytes.length > 1024 ? bytes.sublist(0, 1024) : bytes;
if (probe.contains(0)) return null; // binary
return utf8.decode(bytes, allowMalformed: true);
} catch (_) {
return null;
}
}