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:
@@ -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.
|
||||
|
||||
@@ -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<void> 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);
|
||||
|
||||
@@ -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<IpcResponse> _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<IpcResponse> _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<IpcResponse> _active(IpcRequest req, EditorRegistry r) async {
|
||||
final buf = r.active;
|
||||
if (buf == null) {
|
||||
|
||||
@@ -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<String, CancelToken> _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<void> _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<String, Object?> 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});
|
||||
});
|
||||
}
|
||||
@@ -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<List<SearchMatch>> 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 = <String>[];
|
||||
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 = <Future<List<SearchMatch>>>[
|
||||
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<Future<List<SearchMatch>>> 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<List<SearchMatch>> _runChunk(
|
||||
String rootPath,
|
||||
List<String> 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<List<String>> _chunk(List<String> items, int buckets) {
|
||||
if (buckets <= 1 || items.length <= 1) return [items];
|
||||
final size = (items.length / buckets).ceil();
|
||||
final out = <List<String>>[];
|
||||
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<SearchMatch> grepChunk(
|
||||
String rootPath,
|
||||
List<String> relPaths,
|
||||
SearchQuery query,
|
||||
int maxPerFile,
|
||||
) {
|
||||
final compiled = CompiledQuery(query);
|
||||
final out = <SearchMatch>[];
|
||||
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<SearchMatch> 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<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;
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
@@ -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<String, Object?> toJson() => {
|
||||
'path': path,
|
||||
'line': line,
|
||||
'matchStart': matchStart,
|
||||
'matchEnd': matchEnd,
|
||||
'preview': preview,
|
||||
};
|
||||
|
||||
factory SearchMatch.fromJson(Map<String, Object?> 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<String> include;
|
||||
|
||||
/// Glob patterns; matching paths are skipped (applied after [include]).
|
||||
final List<String> exclude;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'pattern': pattern,
|
||||
'regex': regex,
|
||||
'ignoreCase': ignoreCase,
|
||||
'include': include,
|
||||
'exclude': exclude,
|
||||
};
|
||||
|
||||
factory SearchQuery.fromJson(Map<String, Object?> 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<String> _strings(Object? v) => v is List ? [for (final e in v) '$e'] : const [];
|
||||
}
|
||||
@@ -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