From 4aed6c12a5f009874f6c3288a30e60ef30dc60cb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 31 May 2026 21:41:09 +0200 Subject: [PATCH] add search-and-replace across files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 17 ++ CHANGELOG.md | 4 + .../search/src/find_in_files_controller.dart | 37 +++ lib/builtin/search/src/search_panel_view.dart | 229 +++++++++++++++++- lib/src/daemon/search_commands.dart | 88 +++++++ lib/src/search/replace_engine.dart | 187 ++++++++++++++ .../search/find_in_files_controller_test.dart | 40 +++ .../search/search_panel_view_test.dart | 60 +++++ test/daemon/search_commands_test.dart | 30 +++ test/search/replace_engine_test.dart | 136 +++++++++++ 11 files changed, 821 insertions(+), 8 deletions(-) create mode 100644 lib/src/search/replace_engine.dart create mode 100644 test/search/replace_engine_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 76c45ad2..efe3ae8e 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2377,3 +2377,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-51', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 18:01:23', '2026-05-31 18:01:23', '2026-05-31 18:01:23', NULL, '386ac9084ce3fe9c035a56bba3cd1c42', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-52', 'status', 'in_progress', 'done', NULL, '2026-05-31 18:54:02', '2026-05-31 18:54:02', '2026-05-31 18:54:02', NULL, 'bac566c015a7d599cbb2fc9b3855400b', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-51', 'status', 'in_progress', 'done', NULL, '2026-05-31 18:54:02', '2026-05-31 18:54:02', '2026-05-31 18:54:02', NULL, 'fafab627f731815e6f83bfaf310543a6', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-53', 'status', 'backlog', 'in_progress', NULL, '2026-05-31 19:28:39', '2026-05-31 19:28:39', '2026-05-31 19:28:39', NULL, '5e29490c3b36aa2dce8dece3d0d0091f', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 681b4bf9..2cae882e 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -3043,3 +3043,20 @@ Open-at-line — editor.open takes only {path} today. Extend it with an optional Sidebar panel follows the files/git/decisions TabContribution(slot: Slots.sidebar) pattern; custom widgets, no Material/Cupertino. Files: new lib/builtin/search/{extension,search_controller,search_panel_view}.dart; new lib/src/daemon/search_commands.dart (search.grep + isolate pool); lib/src/files ignore layering; editor_commands.dart + registry.dart (line param); IPC schema + dispatcher registration; CLI `clide search grep` verb.', 'done', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-05-31 18:54:02', NULL, '59534ff6067343e354f4946fe7931d2f', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-53', 'story', NULL, 'search and replace across files', 'Companion to find-in-files. Preview all replacements before applying. Support regex capture groups in replacement. Respects ignore patterns from pql config. + +Refinement (2026-05-31): + +Depends on T-52 — consumes its match model (path, line, start/end offset per match, capture groups) as the replacement input. Sequence after T-52 (T-52 blocks T-53). + +Write path — no files.write IPC exists today (only files.root/read/ls/watch; the sole write path is EditorRegistry.save on an open buffer). Add a `files.write` verb gated by resolveUnderRootFollowingSymlinks (lib/src/files/path_safety.dart). Do NOT route through the editor (would pollute the open-buffer list with dozens of temp buffers). + +Regex capture groups — Dart replaceAllMapped / $1; the T-52 engine must surface match groups. + +Preview — the existing diff view (lib/builtin/diff) is git-only (hardwired to git.diff). Build a ReplacementPreviewController/view that reuses the diff RENDER primitives (DiffLine / _HunkView styling) fed a computed in-memory before/after set. Do NOT generalize/entangle the git DiffController. + +Safety (user decision) — REQUIRE A CLEAN GIT WORKING TREE before apply: refuse with "commit or stash your changes first" if there are unstaged changes, making git the lossless undo layer. Preview shown first; final confirmation via the existing DialogRouter (as used by git discard-confirm). + +Ignore — same full ignore_files: layering as T-52 (D-4). + +Files: lib/src/daemon/files_commands.dart (files.write); new replacement-preview controller/view under lib/builtin/search/; reuse path_safety + diff render primitives; DialogRouter confirm; IPC schema + CLI parity verbs.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-04-23 20:32:06', '2026-05-31 19:28:39', NULL, 'fb6ccea7b9f4b2a2f403f7ade153cfeb', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index c9452cb9..fb01271e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Search-and-replace across the workspace: enter a replacement in the search + panel to preview each rewritten line, then Replace all (regex capture groups + supported). Guarded by a clean-git-tree gate — git is the undo — and a + confirmation. New `search.replace` command (preview + apply). (T-53) - Find-in-files sidebar panel (Ctrl/Cmd+Shift+F): search the workspace with regex and case toggles plus include/exclude globs; results stream in grouped by file and clicking a match opens the editor at that line. (T-52) diff --git a/lib/builtin/search/src/find_in_files_controller.dart b/lib/builtin/search/src/find_in_files_controller.dart index a9b37a5d..332ac7ee 100644 --- a/lib/builtin/search/src/find_in_files_controller.dart +++ b/lib/builtin/search/src/find_in_files_controller.dart @@ -27,6 +27,7 @@ class FindInFilesController extends ChangeNotifier { bool ignoreCase = false; String includeGlobs = ''; String excludeGlobs = ''; + String replacement = ''; // -- Result state --------------------------------------------------------- String? _activeSearchId; @@ -67,6 +68,42 @@ class FindInFilesController extends ChangeNotifier { set include(String v) => includeGlobs = v; set exclude(String v) => excludeGlobs = v; + void setReplacement(String v) { + if (replacement == v) return; + replacement = v; + notifyListeners(); + } + + /// True when the git working tree has no changes — the safety gate for + /// applying a destructive multi-file replace (the user's chosen model: + /// git is the lossless undo layer). + Future isWorkingTreeClean() async { + final r = await ipc.request('git.status'); + return r.ok && r.data['clean'] == true; + } + + /// Apply the current replacement across all matches, then refresh the + /// results. Returns the number of files changed and matches replaced. + /// Callers must gate on [isWorkingTreeClean] + user confirmation first. + Future<({int files, int count})> applyReplace() async { + final r = await ipc.request('search.replace', args: { + 'pattern': pattern, + 'regex': regex, + 'ignoreCase': ignoreCase, + 'include': _split(includeGlobs), + 'exclude': _split(excludeGlobs), + 'replacement': replacement, + 'apply': true, + }); + final files = (r.data['filesChanged'] as num?)?.toInt() ?? 0; + final count = (r.data['totalCount'] as num?)?.toInt() ?? 0; + await run(pattern); // refresh the match list against the new content + return (files: files, count: count); + } + + /// The query the panel is currently running (for preview rendering). + SearchQuery get query => SearchQuery(pattern: pattern, regex: regex, ignoreCase: ignoreCase); + /// Start a search with the current query/options. Cancels any /// in-flight search first and clears prior results. Future run(String query) async { diff --git a/lib/builtin/search/src/search_panel_view.dart b/lib/builtin/search/src/search_panel_view.dart index 4b2006e3..3ed46f4d 100644 --- a/lib/builtin/search/src/search_panel_view.dart +++ b/lib/builtin/search/src/search_panel_view.dart @@ -6,6 +6,7 @@ library; import 'package:clide/builtin/search/src/find_in_files_controller.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/src/search/match.dart'; +import 'package:clide/src/search/replace_engine.dart'; import 'package:clide/widgets/widgets.dart'; import 'package:flutter/widgets.dart'; @@ -33,6 +34,26 @@ class _SearchPanelViewState extends State { super.dispose(); } + Future _replaceAll() async { + final c = _controller!; + if (c.replacement.isEmpty || c.matchCount == 0) return; + final dialog = ClideKernel.of(context).dialog; + if (!await c.isWorkingTreeClean()) { + await dialog.show((ctx, dismiss) => _MessageDialog( + title: 'Working tree not clean', + body: 'Commit or stash your changes before replacing — git is the only undo.', + dismiss: dismiss, + )); + return; + } + final confirmed = await dialog.show((ctx, dismiss) => _ConfirmDialog( + body: 'Replace ${c.matchCount} match(es) across ${c.fileCount} file(s)? This cannot be undone in clide.', + dismiss: dismiss, + )); + if (confirmed != true) return; + await c.applyReplace(); + } + @override Widget build(BuildContext context) { final tokens = ClideTheme.of(context).surface; @@ -79,6 +100,18 @@ class _SearchPanelViewState extends State { ], ), const SizedBox(height: 6), + Row( + children: [ + Expanded(child: ClideFilterBox(hint: 'Replace', debounce: Duration.zero, onChanged: c.setReplacement)), + const SizedBox(width: 6), + _ReplaceAllButton( + enabled: c.replacement.isNotEmpty && c.matchCount > 0, + tokens: tokens, + onTap: _replaceAll, + ), + ], + ), + const SizedBox(height: 6), ClideFilterBox(hint: 'files to include (e.g. *.dart)', debounce: Duration.zero, onChanged: (v) => c.include = v), const SizedBox(height: 4), ClideFilterBox(hint: 'files to exclude', debounce: Duration.zero, onChanged: (v) => c.exclude = v), @@ -94,7 +127,15 @@ class _SearchPanelViewState extends State { child: ListView( padding: EdgeInsets.zero, children: [ - for (final entry in groups.entries) _FileGroup(path: entry.key, matches: entry.value, tokens: tokens, onTap: c.openMatch), + for (final entry in groups.entries) + _FileGroup( + path: entry.key, + matches: entry.value, + tokens: tokens, + onTap: c.openMatch, + query: c.query, + replacement: c.replacement, + ), ], ), ), @@ -169,12 +210,21 @@ class _StatusText extends StatelessWidget { } class _FileGroup extends StatelessWidget { - const _FileGroup({required this.path, required this.matches, required this.tokens, required this.onTap}); + const _FileGroup({ + required this.path, + required this.matches, + required this.tokens, + required this.onTap, + required this.query, + required this.replacement, + }); final String path; final List matches; final SurfaceTokens tokens; final void Function(SearchMatch) onTap; + final SearchQuery query; + final String replacement; @override Widget build(BuildContext context) { @@ -193,18 +243,26 @@ class _FileGroup extends StatelessWidget { ], ), ), - for (final m in matches) _MatchRow(match: m, tokens: tokens, onTap: () => onTap(m)), + for (final m in matches) _MatchRow(match: m, tokens: tokens, onTap: () => onTap(m), query: query, replacement: replacement), ], ); } } class _MatchRow extends StatelessWidget { - const _MatchRow({required this.match, required this.tokens, required this.onTap}); + const _MatchRow({ + required this.match, + required this.tokens, + required this.onTap, + required this.query, + required this.replacement, + }); final SearchMatch match; final SurfaceTokens tokens; final VoidCallback onTap; + final SearchQuery query; + final String replacement; @override Widget build(BuildContext context) { @@ -224,7 +282,7 @@ class _MatchRow extends StatelessWidget { width: 36, child: ClideText('${match.line}', fontSize: clideFontCaption, fontFamily: clideMonoFamily, color: tokens.globalTextMuted), ), - Expanded(child: _highlighted()), + Expanded(child: replacement.isEmpty ? _highlighted() : _preview()), ], ), ), @@ -232,19 +290,174 @@ class _MatchRow extends StatelessWidget { ); } + TextStyle get _base => TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.sidebarForeground); + Widget _highlighted() { final line = match.preview; final start = match.matchStart.clamp(0, line.length); final end = match.matchEnd.clamp(start, line.length); - final base = TextStyle(fontFamily: clideMonoFamily, fontSize: clideFontCaption, color: tokens.sidebarForeground); return RichText( maxLines: 1, overflow: TextOverflow.ellipsis, - text: TextSpan(style: base, children: [ + text: TextSpan(style: _base, children: [ TextSpan(text: line.substring(0, start)), - TextSpan(text: line.substring(start, end), style: base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)), + TextSpan(text: line.substring(start, end), style: _base.copyWith(color: tokens.globalFocus, fontWeight: FontWeight.bold)), TextSpan(text: line.substring(end)), ]), ); } + + /// Replace-preview: the original line struck through, then the + /// rewritten line (computed with the same engine the apply uses). + Widget _preview() { + final after = applyToText(match.preview, query, replacement).text; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan(text: match.preview, style: _base.copyWith(decoration: TextDecoration.lineThrough, color: tokens.globalTextMuted)), + ), + RichText( + maxLines: 1, + overflow: TextOverflow.ellipsis, + text: TextSpan(text: after, style: _base.copyWith(color: tokens.globalFocus)), + ), + ], + ); + } +} + +class _ReplaceAllButton extends StatelessWidget { + const _ReplaceAllButton({required this.enabled, required this.tokens, required this.onTap}); + + final bool enabled; + final SurfaceTokens tokens; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: enabled, + label: 'Replace all', + child: ClideTappable( + onTap: enabled ? onTap : () {}, + builder: (context, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: enabled && hovered ? tokens.listItemHoverBackground : null, + border: Border.all(color: tokens.buttonBorder), + borderRadius: BorderRadius.circular(4), + ), + child: ClideText( + 'Replace all', + fontSize: clideFontCaption, + color: enabled ? tokens.sidebarForeground : tokens.globalTextMuted, + ), + ), + ), + ); + } +} + +class _MessageDialog extends StatelessWidget { + const _MessageDialog({required this.title, required this.body, required this.dismiss}); + + final String title; + final String body; + final void Function([Object?]) dismiss; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return _DialogFrame( + tokens: tokens, + children: [ + ClideText(title, color: tokens.dropdownForeground), + const SizedBox(height: 8), + ClideText(body, fontSize: clideFontCaption, color: tokens.globalTextMuted), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: _DialogButton(label: 'OK', tokens: tokens, onTap: () => dismiss()), + ), + ], + ); + } +} + +class _ConfirmDialog extends StatelessWidget { + const _ConfirmDialog({required this.body, required this.dismiss}); + + final String body; + final void Function([bool?]) dismiss; + + @override + Widget build(BuildContext context) { + final tokens = ClideTheme.of(context).surface; + return _DialogFrame( + tokens: tokens, + children: [ + ClideText(body, color: tokens.dropdownForeground), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _DialogButton(label: 'Cancel', tokens: tokens, onTap: () => dismiss(false)), + const SizedBox(width: 8), + _DialogButton(label: 'Confirm', tokens: tokens, onTap: () => dismiss(true)), + ], + ), + ], + ); + } +} + +class _DialogFrame extends StatelessWidget { + const _DialogFrame({required this.tokens, required this.children}); + final SurfaceTokens tokens; + final List children; + + @override + Widget build(BuildContext context) { + return Container( + width: 360, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: tokens.dropdownBackground, + border: Border.all(color: tokens.modalSurfaceBorder), + borderRadius: BorderRadius.circular(6), + ), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: children), + ); + } +} + +class _DialogButton extends StatelessWidget { + const _DialogButton({required this.label, required this.tokens, required this.onTap}); + final String label; + final SurfaceTokens tokens; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: label, + child: ClideTappable( + onTap: onTap, + builder: (context, hovered, _) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: hovered ? tokens.listItemHoverBackground : null, + border: Border.all(color: tokens.buttonBorder), + borderRadius: BorderRadius.circular(4), + ), + child: ClideText(label, fontSize: clideFontCaption, color: tokens.sidebarForeground), + ), + ), + ); + } } diff --git a/lib/src/daemon/search_commands.dart b/lib/src/daemon/search_commands.dart index 39a3becf..6d45f391 100644 --- a/lib/src/daemon/search_commands.dart +++ b/lib/src/daemon/search_commands.dart @@ -11,12 +11,14 @@ import 'dart:async'; import 'dart:io'; import '../files/ignore.dart'; +import '../files/path_safety.dart'; import '../ipc/command_schema.dart'; import '../ipc/envelope.dart'; import '../ipc/schema_v1.dart'; import '../panes/event_sink.dart'; import '../search/grep_engine.dart'; import '../search/match.dart'; +import '../search/replace_engine.dart'; import 'dispatcher.dart'; /// Owns in-flight searches and streams their results onto the event @@ -55,6 +57,50 @@ class SearchService { _active.remove(id)?.cancel(); } + /// Compute (preview) or perform (apply) a search-and-replace. + /// + /// Preview returns per-file before/after edits without touching disk. + /// Apply writes each changed file's new content through the workspace + /// path-safety guard. The clean-git-tree safety gate is enforced by + /// the caller (the UI checks `git.status` before requesting apply). + Future> replace(SearchQuery query, String replacement, {required bool apply}) async { + final files = await computeReplacements( + root: root, + ignore: ignore, + query: query, + replacement: replacement, + ); + if (!apply) { + return { + 'apply': false, + 'files': [for (final f in files) f.toJson()], + 'fileCount': files.length, + 'totalCount': files.fold(0, (s, f) => s + f.count), + }; + } + final rootPath = root.absolute.path; + var changed = 0; + var total = 0; + for (final f in files) { + final newContent = rewriteFileContent(rootPath, f.path, query, replacement); + if (newContent == null) continue; + final String abs; + try { + abs = resolveUnderRootFollowingSymlinks(root, f.path); + } on PathOutsideRoot { + continue; + } + try { + File(abs).writeAsStringSync(newContent); + changed++; + total += f.count; + } catch (_) { + // skip unwritable files; the rest still apply + } + } + return {'apply': true, 'filesChanged': changed, 'totalCount': total}; + } + Future _run(String id, SearchQuery query, CancelToken cancel) async { try { await for (final batch in grepWorkspace( @@ -101,6 +147,19 @@ const CommandSchema _grepSchema = CommandSchema( }, ); +const CommandSchema _replaceSchema = CommandSchema( + positional: ['pattern', 'replacement'], + args: { + 'pattern': ArgSpec(required: true), + 'replacement': ArgSpec(), + 'regex': ArgSpec(type: ArgType.boolean), + 'ignoreCase': ArgSpec(type: ArgType.boolean), + 'include': ArgSpec(type: ArgType.stringList), + 'exclude': ArgSpec(type: ArgType.stringList), + 'apply': ArgSpec(type: ArgType.boolean), + }, +); + void registerSearchCommands(DaemonDispatcher d, SearchService search) { d.register('search.grep', (req) async { final query = SearchQuery.fromJson(req.args); @@ -118,6 +177,35 @@ void registerSearchCommands(DaemonDispatcher d, SearchService search) { return IpcResponse.ok(id: req.id, data: {'searchId': id}); }, schema: _grepSchema); + d.register('search.replace', (req) async { + final query = SearchQuery.fromJson(req.args); + final replacement = (req.args['replacement'] as String?) ?? ''; + final apply = req.args['apply'] == true; + if (query.pattern.isEmpty) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'search.replace requires a non-empty pattern', + ), + ); + } + try { + final result = await search.replace(query, replacement, apply: apply); + return IpcResponse.ok(id: req.id, data: result); + } on FormatException catch (e) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'invalid regex: ${e.message}', + ), + ); + } + }, schema: _replaceSchema); + d.register('search.cancel', (req) async { final id = req.args['searchId'] as String?; if (id == null || id.isEmpty) { diff --git a/lib/src/search/replace_engine.dart b/lib/src/search/replace_engine.dart new file mode 100644 index 00000000..effe79ed --- /dev/null +++ b/lib/src/search/replace_engine.dart @@ -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 toJson() => {'line': line, 'before': before, 'after': after}; + + factory ReplacementEdit.fromJson(Map 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 edits; + + Map toJson() => { + 'path': path, + 'count': count, + 'edits': [for (final e in edits) e.toJson()], + }; + + factory FileReplacement.fromJson(Map j) => FileReplacement( + path: j['path'] as String, + count: (j['count'] as num).toInt(), + edits: [ + for (final e in (j['edits'] as List? ?? const []).whereType()) ReplacementEdit.fromJson(e.cast()), + ], + ); +} + +/// 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> 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 = []; + 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 = []; + 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; + } +} diff --git a/test/builtin/search/find_in_files_controller_test.dart b/test/builtin/search/find_in_files_controller_test.dart index 80e73c8a..a645837a 100644 --- a/test/builtin/search/find_in_files_controller_test.dart +++ b/test/builtin/search/find_in_files_controller_test.dart @@ -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? 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 + }); } diff --git a/test/builtin/search/search_panel_view_test.dart b/test/builtin/search/search_panel_view_test.dart index 4e39254b..dbaa8f5c 100644 --- a/test/builtin/search/search_panel_view_test.dart +++ b/test/builtin/search/search_panel_view_test.dart @@ -13,6 +13,10 @@ import '../../helpers/widget_harness.dart'; IpcResponse _ok(Map 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 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? 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'); + }); } diff --git a/test/daemon/search_commands_test.dart b/test/daemon/search_commands_test.dart index fdbb7536..53d6c57e 100644 --- a/test/daemon/search_commands_test.dart +++ b/test/daemon/search_commands_test.dart @@ -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); + }); } diff --git a/test/search/replace_engine_test.dart b/test/search/replace_engine_test.dart new file mode 100644 index 00000000..869dfe50 --- /dev/null +++ b/test/search/replace_engine_test.dart @@ -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'); + }); + }); +}