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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 '});
|
||||
|
||||
@@ -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<IpcResponse> call(String cmd, Map<String, Object?> 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<Map>();
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -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<List<SearchMatch>> run(SearchQuery q, {int maxResults = 5000, int maxPerFile = 200}) async {
|
||||
final out = <SearchMatch>[];
|
||||
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<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
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 = <SearchMatch>[];
|
||||
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 = <SearchMatch>[];
|
||||
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 = <SearchMatch>[];
|
||||
await for (final batch in grepWorkspace(
|
||||
root: root,
|
||||
ignore: IgnoreSet([]),
|
||||
query: const SearchQuery(pattern: 'main'),
|
||||
useIsolates: true,
|
||||
)) {
|
||||
out.addAll(batch);
|
||||
}
|
||||
expect(out, isNotEmpty);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user