apply include/exclude globs in search.replace (T-364)

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>
This commit is contained in:
2026-06-12 00:34:47 +02:00
co-authored by Claude Fable 5
parent 8477e302ef
commit 88d72789f4
6 changed files with 51 additions and 5 deletions
+8 -5
View File
@@ -56,11 +56,11 @@ Stream<List<SearchMatch>> grepWorkspace({
final walk = await walkFiles(root: root, ignore: ignore);
if (cancel?.isCancelled ?? false) return;
final includes = [for (final g in query.include) _globToRegExp(g)];
final excludes = [for (final g in query.exclude) _globToRegExp(g)];
final includes = [for (final g in query.include) globToRegExp(g)];
final excludes = [for (final g in query.exclude) globToRegExp(g)];
final candidates = <String>[];
for (final e in walk.files) {
if (_acceptGlobs(e.path, includes, excludes)) candidates.add(e.path);
if (acceptGlobs(e.path, includes, excludes)) candidates.add(e.path);
}
if (candidates.isEmpty) return;
@@ -193,7 +193,10 @@ class CompiledQuery {
// -- Glob filtering ----------------------------------------------------------
bool _acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
/// Whether [path] passes the compiled include/exclude filters. Shared with
/// the replace engine so search and replace can never disagree on scope
/// (T-364).
bool acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
if (includes.isNotEmpty && !includes.any((r) => r.hasMatch(path))) return false;
if (excludes.any((r) => r.hasMatch(path))) return false;
return true;
@@ -202,7 +205,7 @@ bool _acceptGlobs(String path, List<RegExp> includes, List<RegExp> excludes) {
/// Compile a gitignore-flavoured glob to a full-path regex. A `/` in
/// the glob anchors it to the workspace root; otherwise it may match at
/// any depth (basename-style). Supports `*`, `**`, `?`.
RegExp _globToRegExp(String glob) {
RegExp globToRegExp(String glob) {
final anchored = glob.contains('/');
final b = StringBuffer('^');
if (!anchored) b.write(r'(?:.*/)?');
+6
View File
@@ -16,6 +16,7 @@ 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.
@@ -133,9 +134,14 @@ Future<List<FileReplacement>> computeReplacements({
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);
}