From 399a4d3a3f484029390e28f5656391ab8117f189 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 31 May 2026 20:31:18 +0200 Subject: [PATCH] add workspace grep engine + search.grep/cancel, editor.open --line The pure-Dart content-search engine behind find-in-files (D-79): walks the ignore-pruned workspace, fans files across worker isolates (Isolate.run) for parallelism, matches each line with a literal indexOf fast-path or a RegExp, and streams match batches with cooperative cancellation. No ripgrep dependency; the search.grep IPC contract is engine-agnostic so an rg accelerator can slot in later. search.grep returns a searchId and streams search.match / search.done (or search.error) events, mirroring files.watch; search.cancel stops an in-flight search. The service reuses the files service's resolved ignore set so both honour the same ignore_files: layering. editor.open gains an optional 1-based line argument: it converts the line to a byte offset and sets the initial selection, enabling click-to-line from search results. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 + lib/main.dart | 9 + lib/src/daemon/editor_commands.dart | 22 +++ lib/src/daemon/search_commands.dart | 136 ++++++++++++++ lib/src/search/grep_engine.dart | 255 ++++++++++++++++++++++++++ lib/src/search/match.dart | 91 +++++++++ test/daemon/editor_commands_test.dart | 22 +++ test/daemon/search_commands_test.dart | 85 +++++++++ test/search/grep_engine_test.dart | 159 ++++++++++++++++ test/search/match_test.dart | 39 ++++ 10 files changed, 824 insertions(+) create mode 100644 lib/src/daemon/search_commands.dart create mode 100644 lib/src/search/grep_engine.dart create mode 100644 lib/src/search/match.dart create mode 100644 test/daemon/search_commands_test.dart create mode 100644 test/search/grep_engine_test.dart create mode 100644 test/search/match_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 773bf451..ddbfb4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,12 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Workspace content-search engine with `search.grep` / `search.cancel` commands: + a pure-Dart, isolate-parallel grep (literal or regex, case + include/exclude + glob filters) that streams matches and honours the `ignore_files:` chain. The + engine is in-process — no ripgrep dependency (D-79). (T-52) +- `editor.open` accepts an optional 1-based `line` to position the initial + selection on open (backs find-in-files click-to-line). (T-52) - Quick-open file finder (Ctrl/Cmd+P): a fuzzy file picker overlay over the whole workspace, separate from the command palette. Empty query lists recent files; Enter opens `.md` in the markdown reader and other files in the editor. diff --git a/lib/main.dart b/lib/main.dart index 4460df7f..40bce37b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -37,6 +37,7 @@ import 'package:clide/src/daemon/pane_commands.dart'; import 'package:clide/src/daemon/panel_commands.dart'; import 'package:clide/src/daemon/panel_resizer_kernel.dart'; import 'package:clide/src/daemon/pql_commands.dart'; +import 'package:clide/src/daemon/search_commands.dart'; import 'package:clide/src/editor/registry.dart' show EditorRegistry; import 'package:clide/src/git/client.dart'; import 'package:clide/src/cli/argv_dispatch.dart'; @@ -166,6 +167,14 @@ Future main() async { registerPaneCommands(dispatcher, paneRegistry); final filesService = FilesService(root: workRoot, events: eventSink); registerFilesCommands(dispatcher, filesService); + // Search reuses the files service's resolved ignore set so the + // grep honours the same ignore_files: layering (D-4 / D-79). + final searchService = SearchService( + root: workRoot, + ignore: filesService.ignore, + events: eventSink, + ); + registerSearchCommands(dispatcher, searchService); final editorRegistry = EditorRegistry(events: eventSink, workspaceRoot: workRoot); registerEditorCommands(dispatcher, editorRegistry); final gitClient = GitClient(toolchain: tc, workDir: workRoot); diff --git a/lib/src/daemon/editor_commands.dart b/lib/src/daemon/editor_commands.dart index 337585bf..ed9a624b 100644 --- a/lib/src/daemon/editor_commands.dart +++ b/lib/src/daemon/editor_commands.dart @@ -11,6 +11,7 @@ library; import 'dart:io' show FileSystemException; +import '../editor/buffer.dart' show Selection; import '../editor/registry.dart'; import '../ipc/envelope.dart'; import '../ipc/errno_mapping.dart'; @@ -66,6 +67,14 @@ Future _open(IpcRequest req, EditorRegistry r) async { } try { final buf = await r.open(path); + // Optional 1-based line: jump the initial selection to that line's + // start (used by find-in-files click-to-line, T-52). Out-of-range + // lines clamp via setSelection. A non-numeric/<1 line is ignored. + final rawLine = req.args['line']; + final line = rawLine is num ? rawLine.toInt() : int.tryParse('$rawLine'); + if (line != null && line >= 1) { + r.setSelection(buf.id, Selection.collapsed(_offsetForLine(buf.content, line))); + } return IpcResponse.ok(id: req.id, data: buf.toJson()); } on FileSystemException catch (e) { final errno = e.osError?.errorCode; @@ -95,6 +104,19 @@ Future _open(IpcRequest req, EditorRegistry r) async { } } +/// Byte offset of the start of the 1-based [line] in [content]. Lines +/// past the end clamp to the content length. +int _offsetForLine(String content, int line) { + if (line <= 1) return 0; + var remaining = line - 1; + var i = 0; + while (i < content.length && remaining > 0) { + if (content.codeUnitAt(i) == 0x0A) remaining--; + i++; + } + return i; +} + Future _active(IpcRequest req, EditorRegistry r) async { final buf = r.active; if (buf == null) { diff --git a/lib/src/daemon/search_commands.dart b/lib/src/daemon/search_commands.dart new file mode 100644 index 00000000..39a3becf --- /dev/null +++ b/lib/src/daemon/search_commands.dart @@ -0,0 +1,136 @@ +/// Registers `search.*` command handlers (T-52, per D-79). +/// +/// `search.grep` kicks off a workspace content search and returns a +/// `searchId` immediately; match batches stream back as `search.match` +/// events, terminated by `search.done` (or `search.error`). This +/// mirrors `files.watch`'s event-streaming shape. `search.cancel` stops +/// an in-flight search by id. +library; + +import 'dart:async'; +import 'dart:io'; + +import '../files/ignore.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 'dispatcher.dart'; + +/// Owns in-flight searches and streams their results onto the event +/// sink. One instance per workspace, constructed alongside the other +/// daemon services. +class SearchService { + SearchService({ + required this.root, + required this.ignore, + required this.events, + this.useIsolates = true, + }); + + final Directory root; + final IgnoreSet ignore; + final DaemonEventSink events; + + /// Whether the engine fans work across isolates. Tests set this false + /// for deterministic, in-process runs. + final bool useIsolates; + + final Map _active = {}; + int _seq = 0; + + /// Begin a search; returns the id that scopes its stream of events. + String start(SearchQuery query) { + final id = 'search-${_seq++}'; + final cancel = CancelToken(); + _active[id] = cancel; + unawaited(_run(id, query, cancel)); + return id; + } + + /// Cancel an in-flight search (no-op if already finished). + void cancel(String id) { + _active.remove(id)?.cancel(); + } + + Future _run(String id, SearchQuery query, CancelToken cancel) async { + try { + await for (final batch in grepWorkspace( + root: root, + ignore: ignore, + query: query, + cancel: cancel, + useIsolates: useIsolates, + )) { + if (cancel.isCancelled) break; + _emit('search.match', { + 'searchId': id, + 'matches': [for (final m in batch) m.toJson()], + }); + } + _emit('search.done', {'searchId': id, 'cancelled': cancel.isCancelled}); + } on FormatException catch (e) { + _emit('search.error', {'searchId': id, 'message': 'invalid regex: ${e.message}'}); + } catch (e) { + _emit('search.error', {'searchId': id, 'message': '$e'}); + } finally { + _active.remove(id); + } + } + + void _emit(String kind, Map data) { + events.emit(IpcEvent( + subsystem: 'search', + kind: kind, + timestamp: DateTime.now().toUtc(), + data: data, + )); + } +} + +const CommandSchema _grepSchema = CommandSchema( + positional: ['pattern'], + args: { + 'pattern': ArgSpec(required: true), + 'regex': ArgSpec(type: ArgType.boolean), + 'ignoreCase': ArgSpec(type: ArgType.boolean), + 'include': ArgSpec(type: ArgType.stringList), + 'exclude': ArgSpec(type: ArgType.stringList), + }, +); + +void registerSearchCommands(DaemonDispatcher d, SearchService search) { + d.register('search.grep', (req) async { + final query = SearchQuery.fromJson(req.args); + if (query.pattern.isEmpty) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'search.grep requires a non-empty pattern', + ), + ); + } + final id = search.start(query); + return IpcResponse.ok(id: req.id, data: {'searchId': id}); + }, schema: _grepSchema); + + d.register('search.cancel', (req) async { + final id = req.args['searchId'] as String?; + if (id == null || id.isEmpty) { + return IpcResponse.err( + id: req.id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: 'search.cancel requires a searchId', + ), + ); + } + search.cancel(id); + return IpcResponse.ok(id: req.id, data: {'cancelled': id}); + }); +} diff --git a/lib/src/search/grep_engine.dart b/lib/src/search/grep_engine.dart new file mode 100644 index 00000000..351c1305 --- /dev/null +++ b/lib/src/search/grep_engine.dart @@ -0,0 +1,255 @@ +/// Pure-Dart workspace content-grep engine (T-52, per D-79). +/// +/// Walks the workspace (ignore-pruned via [walkFiles]), fans the file +/// list across worker isolates ([Isolate.run]) for true parallelism, +/// matches each line with a literal `indexOf` fast-path or a [RegExp], +/// and **streams** match batches back so the UI shows first hits before +/// the whole tree is scanned. Cancellation is checked between chunks. +/// +/// No external binary, single-process, cross-platform — the rationale +/// and the ripgrep-accelerator escape hatch are recorded in D-79. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import '../files/ignore.dart'; +import '../files/listing.dart'; +import 'match.dart'; + +/// Cooperative cancellation flag for an in-flight [grepWorkspace]. +class CancelToken { + bool _cancelled = false; + bool get isCancelled => _cancelled; + void cancel() => _cancelled = true; +} + +/// Stream match batches for [query] under [root]. +/// +/// Yields a list per completed file-chunk (in chunk order) so the UI +/// can append incrementally. Stops after [maxResults] total matches +/// (the final batch is trimmed) or when [cancel] fires. [maxPerFile] +/// bounds matches from any single file. Set [useIsolates] false to run +/// in-process (deterministic; used by tests). +/// +/// Throws [FormatException] for an invalid regex pattern before any +/// scanning begins. +Stream> grepWorkspace({ + required Directory root, + required IgnoreSet ignore, + required SearchQuery query, + int maxResults = 5000, + int maxPerFile = 200, + int? concurrency, + CancelToken? cancel, + bool useIsolates = true, +}) async* { + if (query.pattern.isEmpty) return; + // Validate the regex up front so a bad pattern surfaces as an error, + // not as silent zero results from every isolate. + if (query.regex) { + RegExp(query.pattern, caseSensitive: !query.ignoreCase); + } + + 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 candidates = []; + for (final e in walk.files) { + if (_acceptGlobs(e.path, includes, excludes)) candidates.add(e.path); + } + if (candidates.isEmpty) return; + + final n = (concurrency ?? Platform.numberOfProcessors).clamp(1, 32); + final chunks = _chunk(candidates, n); + final rootPath = root.absolute.path; + + // Launch every chunk concurrently; await in chunk order to stream. + final futures = >>[ + for (final c in chunks) _runChunk(rootPath, c, query, maxPerFile, useIsolates), + ]; + + var emitted = 0; + for (final f in futures) { + if (cancel?.isCancelled ?? false) { + _drain(futures); + return; + } + final matches = await f; + if (cancel?.isCancelled ?? false) { + _drain(futures); + return; + } + if (matches.isEmpty) continue; + final remaining = maxResults - emitted; + final slice = matches.length > remaining ? matches.sublist(0, remaining) : matches; + emitted += slice.length; + yield slice; + if (emitted >= maxResults) { + _drain(futures); + return; + } + } +} + +void _drain(List>> futures) { + // Swallow results/errors of any still-running chunks so they don't + // surface as unhandled async errors after we stop reading. + for (final f in futures) { + unawaited(f.then((_) {}, onError: (_) {})); + } +} + +Future> _runChunk( + String rootPath, + List paths, + SearchQuery query, + int maxPerFile, + bool useIsolates, +) { + if (useIsolates) { + return Isolate.run(() => grepChunk(rootPath, paths, query, maxPerFile)); + } + return Future.value(grepChunk(rootPath, paths, query, maxPerFile)); +} + +/// Split [items] into at most [buckets] contiguous chunks. +List> _chunk(List items, int buckets) { + if (buckets <= 1 || items.length <= 1) return [items]; + final size = (items.length / buckets).ceil(); + final out = >[]; + for (var i = 0; i < items.length; i += size) { + out.add(items.sublist(i, (i + size).clamp(0, items.length))); + } + return out; +} + +// -- Isolate-side work (top-level, sendable) -------------------------------- + +/// Grep a chunk of files. Runs in a worker isolate (or in-process for +/// tests). Reads each file, skips binaries, and collects up to +/// [maxPerFile] matches per file. +List grepChunk( + String rootPath, + List relPaths, + SearchQuery query, + int maxPerFile, +) { + final compiled = CompiledQuery(query); + final out = []; + for (final rel in relPaths) { + final file = File('$rootPath/$rel'); + String content; + try { + final bytes = file.readAsBytesSync(); + // Binary sniff: a NUL in the first 1 KiB → skip. + final probe = bytes.length > 1024 ? bytes.sublist(0, 1024) : bytes; + if (probe.contains(0)) continue; + content = utf8.decode(bytes, allowMalformed: true); + } catch (_) { + continue; // unreadable / vanished — skip + } + grepContent(rel, content, compiled, maxPerFile, out); + } + return out; +} + +/// Match [content]'s lines, appending up to [maxPerFile] hits to [out]. +/// Exposed (with a pre-built [compiled]) for unit testing without I/O. +void grepContent( + String relPath, + String content, + CompiledQuery compiled, + int maxPerFile, + List out, +) { + var lineNo = 0; + final added0 = out.length; + for (final line in const LineSplitter().convert(content)) { + lineNo++; + for (final span in compiled.matches(line)) { + out.add(SearchMatch( + path: relPath, + line: lineNo, + matchStart: span.$1, + matchEnd: span.$2, + preview: line.length > 500 ? line.substring(0, 500) : line, + )); + if (out.length - added0 >= maxPerFile) return; + } + } +} + +/// A compiled query: a [RegExp] when [SearchQuery.regex], else a literal +/// matcher with an optional case-insensitive fast-path. +class CompiledQuery { + CompiledQuery(SearchQuery q) + : _regex = q.regex ? RegExp(q.pattern, caseSensitive: !q.ignoreCase) : null, + _needle = q.regex ? '' : (q.ignoreCase ? q.pattern.toLowerCase() : q.pattern), + _ignoreCase = q.ignoreCase; + + final RegExp? _regex; + final String _needle; + final bool _ignoreCase; + + /// All (start, end) match spans within [line]. + List<(int, int)> matches(String line) { + final re = _regex; + if (re != null) { + return [for (final m in re.allMatches(line)) (m.start, m.end)]; + } + if (_needle.isEmpty) return const []; + final hay = _ignoreCase ? line.toLowerCase() : line; + final spans = <(int, int)>[]; + var from = 0; + while (true) { + final i = hay.indexOf(_needle, from); + if (i < 0) break; + spans.add((i, i + _needle.length)); + from = i + _needle.length; + } + return spans; + } +} + +// -- Glob filtering ---------------------------------------------------------- + +bool _acceptGlobs(String path, List includes, List excludes) { + if (includes.isNotEmpty && !includes.any((r) => r.hasMatch(path))) return false; + if (excludes.any((r) => r.hasMatch(path))) return false; + return true; +} + +/// 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) { + final anchored = glob.contains('/'); + final b = StringBuffer('^'); + if (!anchored) b.write(r'(?:.*/)?'); + var i = 0; + while (i < glob.length) { + final c = glob[i]; + if (c == '*') { + if (i + 1 < glob.length && glob[i + 1] == '*') { + b.write('.*'); + i += 2; + continue; + } + b.write('[^/]*'); + } else if (c == '?') { + b.write('[^/]'); + } else if (r'.^$+(){}[]|\'.contains(c)) { + b.write('\\$c'); + } else { + b.write(c); + } + i++; + } + b.write(r'$'); + return RegExp(b.toString()); +} diff --git a/lib/src/search/match.dart b/lib/src/search/match.dart new file mode 100644 index 00000000..c30124b4 --- /dev/null +++ b/lib/src/search/match.dart @@ -0,0 +1,91 @@ +/// Data types for workspace content search (T-52 / T-53, per D-79). +/// +/// Flutter-free by construction: shared by the grep engine (which runs +/// in worker isolates), the `search.*` IPC layer, and the search panel +/// UI. Plain data so instances are sendable across isolates. +library; + +/// One match produced by the workspace grep. Carries enough to render a +/// result, navigate to the line, and (for replace, T-53) address the +/// matched span within the line. +class SearchMatch { + const SearchMatch({ + required this.path, + required this.line, + required this.matchStart, + required this.matchEnd, + required this.preview, + }); + + /// Repo-relative, forward-slashed path of the file. + final String path; + + /// 1-based line number of the match. + final int line; + + /// 0-based character offset of the match start within the line. + final int matchStart; + + /// 0-based character offset of the match end (exclusive) within the line. + final int matchEnd; + + /// The matched line's text (capped for transport/render). + final String preview; + + Map toJson() => { + 'path': path, + 'line': line, + 'matchStart': matchStart, + 'matchEnd': matchEnd, + 'preview': preview, + }; + + factory SearchMatch.fromJson(Map j) => SearchMatch( + path: j['path'] as String, + line: (j['line'] as num).toInt(), + matchStart: (j['matchStart'] as num).toInt(), + matchEnd: (j['matchEnd'] as num).toInt(), + preview: j['preview'] as String, + ); +} + +/// Parameters for a workspace search. +class SearchQuery { + const SearchQuery({ + required this.pattern, + this.regex = false, + this.ignoreCase = false, + this.include = const [], + this.exclude = const [], + }); + + /// The literal text (when [regex] is false) or regular expression + /// source (when true) to search for. + final String pattern; + final bool regex; + final bool ignoreCase; + + /// Glob patterns; when non-empty, only matching paths are searched. + final List include; + + /// Glob patterns; matching paths are skipped (applied after [include]). + final List exclude; + + Map toJson() => { + 'pattern': pattern, + 'regex': regex, + 'ignoreCase': ignoreCase, + 'include': include, + 'exclude': exclude, + }; + + factory SearchQuery.fromJson(Map j) => SearchQuery( + pattern: (j['pattern'] as String?) ?? '', + regex: j['regex'] == true, + ignoreCase: j['ignoreCase'] == true, + include: _strings(j['include']), + exclude: _strings(j['exclude']), + ); + + static List _strings(Object? v) => v is List ? [for (final e in v) '$e'] : const []; +} diff --git a/test/daemon/editor_commands_test.dart b/test/daemon/editor_commands_test.dart index d13a3791..baab26b2 100644 --- a/test/daemon/editor_commands_test.dart +++ b/test/daemon/editor_commands_test.dart @@ -47,6 +47,28 @@ void main() { expect(act['path'], 'doc.md'); }); + test('editor.open with a 1-based line jumps the initial selection (T-52)', () async { + await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\nthree\n'); + final r = await call('editor.open', {'path': 'multi.txt', 'line': 3}); + expect(r.ok, isTrue); + final sel = r.data['selection'] as Map; + // Line 3 starts after 'one\n' + 'two\n' = 8 characters. + expect(sel['start'], 8); + expect(sel['end'], 8); + }); + + test('editor.open without a line opens at the top', () async { + await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\n'); + final r = await call('editor.open', {'path': 'multi.txt'}); + expect((r.data['selection'] as Map)['start'], 0); + }); + + test('editor.open with an out-of-range line clamps to the content end', () async { + await File('${sandbox.path}/multi.txt').writeAsString('one\ntwo\n'); // 8 chars + final r = await call('editor.open', {'path': 'multi.txt', 'line': 999}); + expect((r.data['selection'] as Map)['start'], 8); + }); + test('editor.insert without id targets the active buffer', () async { await call('editor.open', {'path': 'doc.md'}); final r = await call('editor.insert', {'text': 'X '}); diff --git a/test/daemon/search_commands_test.dart b/test/daemon/search_commands_test.dart new file mode 100644 index 00000000..fdbb7536 --- /dev/null +++ b/test/daemon/search_commands_test.dart @@ -0,0 +1,85 @@ +/// Tests for the `search.*` command handlers (T-52 / D-79). +library; + +import 'dart:io'; + +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/search_commands.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory dir; + late RecordingEventSink sink; + late DaemonDispatcher d; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('clide-search-cmd-'); + File('${dir.path}/a.dart').writeAsStringSync('final answer = 42;\n'); + File('${dir.path}/b.dart').writeAsStringSync('// no hits here\n'); + sink = RecordingEventSink(); + final service = SearchService( + root: dir, + ignore: IgnoreSet([]), + events: sink, + useIsolates: false, + ); + d = DaemonDispatcher(); + registerSearchCommands(d, service); + }); + tearDown(() async => dir.delete(recursive: true)); + + Future call(String cmd, Map args) => d.dispatch(IpcRequest(id: '1', cmd: cmd, args: args)); + + test('search.grep returns a searchId and streams match + done', () async { + // Subscribe before dispatching: the result events are broadcast and + // can fire before a post-call listener would attach. + final doneFuture = sink.stream.firstWhere((e) => e.kind == 'search.done'); + final r = await call('search.grep', const {'pattern': 'answer'}); + expect(r.ok, isTrue); + final id = r.data['searchId'] as String; + + final done = await doneFuture; + expect(done.data['searchId'], id); + expect(done.data['cancelled'], isFalse); + + final matches = sink.events.where((e) => e.kind == 'search.match').toList(); + expect(matches, isNotEmpty); + final batch = (matches.first.data['matches'] as List).cast(); + expect(batch.first['path'], 'a.dart'); + expect(batch.first['line'], 1); + }); + + test('search.grep with no matches still emits done', () async { + final doneFuture = sink.stream.firstWhere((e) => e.kind == 'search.done'); + await call('search.grep', const {'pattern': 'zzz-not-present'}); + final done = await doneFuture; + expect(done.data['cancelled'], isFalse); + expect(sink.events.where((e) => e.kind == 'search.match'), isEmpty); + }); + + test('empty pattern is a userError', () async { + final r = await call('search.grep', const {'pattern': ''}); + expect(r.ok, isFalse); + expect(r.error!.kind, IpcErrorKind.userError); + }); + + test('invalid regex emits a search.error event', () async { + final errFuture = sink.stream.firstWhere((e) => e.kind == 'search.error'); + final r = await call('search.grep', const {'pattern': '(unclosed', 'regex': true}); + expect(r.ok, isTrue); // the request is accepted; the error streams + final err = await errFuture; + expect(err.data['message'], contains('invalid regex')); + }); + + test('search.cancel requires a searchId', () async { + final r = await call('search.cancel', const {}); + expect(r.ok, isFalse); + expect(r.error!.kind, IpcErrorKind.userError); + }); + + test('search.cancel acks a (possibly finished) id', () async { + final r = await call('search.cancel', const {'searchId': 'search-0'}); + expect(r.ok, isTrue); + expect(r.data['cancelled'], 'search-0'); + }); +} diff --git a/test/search/grep_engine_test.dart b/test/search/grep_engine_test.dart new file mode 100644 index 00000000..ca95b679 --- /dev/null +++ b/test/search/grep_engine_test.dart @@ -0,0 +1,159 @@ +/// Tests for the pure-Dart grep engine (T-52 / D-79). Run in-process +/// (`useIsolates: false`) for determinism — the isolate path is the +/// same code, parallelised. +library; + +import 'dart:io'; + +import 'package:clide/src/files/ignore.dart'; +import 'package:clide/src/search/grep_engine.dart'; +import 'package:clide/src/search/match.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory root; + + setUp(() async { + root = await Directory.systemTemp.createTemp('clide-grep-'); + File('${root.path}/a.dart').writeAsStringSync('void main() {}\nfinal x = 1;\n'); + File('${root.path}/b.dart').writeAsStringSync('// TODO: fix\nfinal y = main;\n'); + Directory('${root.path}/sub').createSync(); + File('${root.path}/sub/c.txt').writeAsStringSync('main main main\n'); + }); + tearDown(() async => root.delete(recursive: true)); + + Future> run(SearchQuery q, {int maxResults = 5000, int maxPerFile = 200}) async { + final out = []; + await for (final batch in grepWorkspace( + root: root, + ignore: IgnoreSet([]), + query: q, + useIsolates: false, + concurrency: 1, + maxResults: maxResults, + maxPerFile: maxPerFile, + )) { + out.addAll(batch); + } + return out; + } + + test('literal match finds lines across files', () async { + final r = await run(const SearchQuery(pattern: 'main')); + final paths = r.map((m) => m.path).toSet(); + expect(paths, containsAll(['a.dart', 'b.dart', 'sub/c.txt'])); + final aMatch = r.firstWhere((m) => m.path == 'a.dart'); + expect(aMatch.line, 1); + expect(aMatch.preview, 'void main() {}'); + expect(aMatch.matchStart, 5); + expect(aMatch.matchEnd, 9); + }); + + test('emits one match per occurrence within a line', () async { + final r = await run(const SearchQuery(pattern: 'main')); + final cTxt = r.where((m) => m.path == 'sub/c.txt').toList(); + expect(cTxt, hasLength(3)); + }); + + test('case-insensitive literal match', () async { + final r = await run(const SearchQuery(pattern: 'TODO', ignoreCase: true)); + // 'TODO' present as-is; also matches regardless of case toggle. + expect(r.any((m) => m.path == 'b.dart'), isTrue); + final lower = await run(const SearchQuery(pattern: 'todo', ignoreCase: true)); + expect(lower.any((m) => m.path == 'b.dart'), isTrue); + }); + + test('case-sensitive miss when case differs', () async { + final r = await run(const SearchQuery(pattern: 'todo')); + expect(r.where((m) => m.path == 'b.dart'), isEmpty); + }); + + test('regex match with anchors', () async { + final r = await run(const SearchQuery(pattern: r'final \w+', regex: true)); + expect(r.map((m) => m.path).toSet(), containsAll(['a.dart', 'b.dart'])); + }); + + test('invalid regex throws FormatException', () async { + expect( + () => run(const SearchQuery(pattern: '(unclosed', regex: true)), + throwsA(isA()), + ); + }); + + test('include glob restricts to matching files', () async { + final r = await run(const SearchQuery(pattern: 'main', include: ['*.dart'])); + expect(r.every((m) => m.path.endsWith('.dart')), isTrue); + expect(r.any((m) => m.path == 'sub/c.txt'), isFalse); + }); + + test('exclude glob removes matching files', () async { + final r = await run(const SearchQuery(pattern: 'main', exclude: ['*.txt'])); + expect(r.any((m) => m.path == 'sub/c.txt'), isFalse); + expect(r.any((m) => m.path == 'a.dart'), isTrue); + }); + + test('maxPerFile caps matches from one file', () async { + final r = await run(const SearchQuery(pattern: 'main'), maxPerFile: 1); + expect(r.where((m) => m.path == 'sub/c.txt'), hasLength(1)); + }); + + test('maxResults caps total matches', () async { + final r = await run(const SearchQuery(pattern: 'main'), maxResults: 2); + expect(r, hasLength(2)); + }); + + test('empty pattern yields nothing', () async { + expect(await run(const SearchQuery(pattern: '')), isEmpty); + }); + + test('binary files are skipped', () async { + File('${root.path}/blob.bin').writeAsBytesSync([0x6d, 0x61, 0x69, 0x6e, 0x00, 0x6d, 0x61, 0x69, 0x6e]); + final r = await run(const SearchQuery(pattern: 'main')); + expect(r.any((m) => m.path == 'blob.bin'), isFalse); + }); + + test('cancellation stops the stream early', () async { + final cancel = CancelToken()..cancel(); + final out = []; + await for (final batch in grepWorkspace( + root: root, + ignore: IgnoreSet([]), + query: const SearchQuery(pattern: 'main'), + useIsolates: false, + concurrency: 1, + cancel: cancel, + )) { + out.addAll(batch); + } + expect(out, isEmpty); + }); + + test('ignored files are not searched', () async { + final out = []; + await for (final batch in grepWorkspace( + root: root, + ignore: IgnoreSet.parse(const ['*.txt\n']), + query: const SearchQuery(pattern: 'main'), + useIsolates: false, + concurrency: 1, + )) { + out.addAll(batch); + } + expect(out.any((m) => m.path == 'sub/c.txt'), isFalse); + }); + + // Spawns real worker isolates — runs in the --concurrency=1 serial + // pass to avoid competing with the parallel flutter pool (T-193). + test('runs across isolates without error (smoke)', tags: ['serial'], () async { + final out = []; + await for (final batch in grepWorkspace( + root: root, + ignore: IgnoreSet([]), + query: const SearchQuery(pattern: 'main'), + useIsolates: true, + )) { + out.addAll(batch); + } + expect(out, isNotEmpty); + }); +} diff --git a/test/search/match_test.dart b/test/search/match_test.dart new file mode 100644 index 00000000..c93d2242 --- /dev/null +++ b/test/search/match_test.dart @@ -0,0 +1,39 @@ +/// Round-trip tests for the search data types (T-52). +library; + +import 'package:clide/src/search/match.dart'; +import 'package:test/test.dart'; + +void main() { + test('SearchMatch JSON round-trips', () { + const m = SearchMatch(path: 'lib/a.dart', line: 12, matchStart: 4, matchEnd: 8, preview: 'final x = 1;'); + final back = SearchMatch.fromJson(m.toJson()); + expect(back.path, m.path); + expect(back.line, m.line); + expect(back.matchStart, m.matchStart); + expect(back.matchEnd, m.matchEnd); + expect(back.preview, m.preview); + }); + + test('SearchQuery JSON round-trips', () { + const q = SearchQuery(pattern: 'foo', regex: true, ignoreCase: true, include: ['*.dart'], exclude: ['build/**']); + final back = SearchQuery.fromJson(q.toJson()); + expect(back.pattern, 'foo'); + expect(back.regex, isTrue); + expect(back.ignoreCase, isTrue); + expect(back.include, ['*.dart']); + expect(back.exclude, ['build/**']); + }); + + test('SearchQuery.fromJson tolerates missing/odd fields', () { + final q = SearchQuery.fromJson(const {'pattern': 'x'}); + expect(q.regex, isFalse); + expect(q.ignoreCase, isFalse); + expect(q.include, isEmpty); + expect(q.exclude, isEmpty); + final q2 = SearchQuery.fromJson(const {}); + expect(q2.pattern, ''); + final q3 = SearchQuery.fromJson(const {'pattern': 'x', 'include': 'not-a-list'}); + expect(q3.include, isEmpty); + }); +}