computeReplacements accepted the query's glob filters and silently dropped them — replace could rewrite files the equivalent search would never have matched. The grep engine's glob helpers are now public and shared, so search and replace can't disagree on scope; both the preview and the apply path go through the filtered list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
189 lines
6.5 KiB
Dart
189 lines
6.5 KiB
Dart
/// 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 'grep_engine.dart' show acceptGlobs, globToRegExp;
|
|
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;
|
|
// Same compiled glob filters as the grep engine — replace must never
|
|
// touch a file the equivalent search wouldn't have matched (T-364).
|
|
final includes = [for (final g in query.include) globToRegExp(g)];
|
|
final excludes = [for (final g in query.exclude) globToRegExp(g)];
|
|
final out = <FileReplacement>[];
|
|
for (final entry in walk.files) {
|
|
if (out.length >= maxFiles) break;
|
|
if (!acceptGlobs(entry.path, includes, excludes)) continue;
|
|
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;
|
|
}
|
|
}
|