lattice_multibuffer/providers/search.rs
1//! M.6 (2026-06-01): SearchProvider — the worked
2//! `MultibufferProvider` example.
3//!
4//! Architecture: `multibuffer-views.md` §3.7 "Worked example —
5//! SearchProvider end-to-end". User runs `:search "TODO"`; the
6//! provider:
7//!
8//! 1. Pulls the `ProjectSearchService` handle from
9//! `activator.services()`.
10//! 2. Opens an empty multibuffer view via
11//! `create_multibuffer_view`; the view auto-subscribes to
12//! source events via M.4.
13//! 3. Seeds initial provider state (query, options, scanning).
14//! 4. Sets the headerline to `InProgress { label: "Searching" }`.
15//! 5. Activates `ProjectSearchMode` (this minor's
16//! `on_activate` subscribes to `ProjectSearchBatchReady` +
17//! `ProjectSearchCompleted` and forwards them into
18//! `MultibufferDocumentHandle::append_excerpts` /
19//! `set_headerline`).
20//! 6. Spawns the async scan task via the service.
21//!
22//! The scan task runs on tokio worker threads (never the UI
23//! thread, per paramount goal #1) — walks the project tree via
24//! `ignore::Walk`, matches literal queries against each file,
25//! batches hits, publishes `ProjectSearchBatchReady` events.
26
27use lattice_protocol::CancellationToken;
28use std::path::{Path, PathBuf};
29use std::sync::{Arc, RwLock};
30
31use lattice_config::OptionOverrideSet;
32use lattice_core::{BufferFlags, BufferId};
33use lattice_grammar::{CommandRegistry, CommandRegistryHandle};
34use lattice_mode::{
35 ActionContext, ActionHandlerRegistration, ActionHandlerRegistryHandle, CapabilitySet,
36 LifecycleFuture, Mode, ModeActivator, ModeContext, ModeId, ModeKind, ModeRegistry,
37 ServiceRegistry,
38};
39use lattice_runtime::{Document, EventBus, spawn_document};
40use tokio::sync::mpsc;
41
42use crate::registry::MultibufferRegistryHandle;
43use crate::view::create_multibuffer_view;
44use crate::{Excerpt, ExcerptHeader, HeaderlineStatus};
45
46// ─────────────────────────────────────────────────────────────────
47// Public state types
48// ─────────────────────────────────────────────────────────────────
49
50// ─────────────────────────────────────────────────────────────────
51// Typed options (defined by ProjectSearchMode)
52// K.4.6 follow-up (2026-06-02): per
53// [[feedback_mode_owns_its_surface]] the project-search minor
54// mode owns its options. Bound to the `search` group registered
55// in `lattice-config::group`; user customizes via
56// `:set search.context_size=3` or `:customize search`.
57// ─────────────────────────────────────────────────────────────────
58
59lattice_config::options! {
60 group = lattice_config::Search;
61
62 /// Number of context lines to show above and below each
63 /// matched line in `:search` results. Default `0` (no
64 /// context — only matched lines appear, matching the
65 /// "1 line per hit, 1 header per file" UX).
66 ///
67 /// Mirrors grep's `-C N` convention
68 /// ([[feedback_convention_first]]): `:set search.context_size=3`
69 /// yields ±3 lines of context per hit, with adjacent
70 /// clusters merged when ranges overlap or touch — the
71 /// same shape grep / ripgrep / ag produce.
72 ///
73 /// The substrate's `compose_header_rows` dedupes consecutive
74 /// same-source excerpts to a single header regardless of
75 /// `context_size`, so increasing this widens the windows
76 /// under each file's header rather than adding new headers.
77 #[name("search.context_size")]
78 pub SearchContextSize: i64 = 0;
79}
80
81/// User-supplied scan parameters.
82#[derive(Debug, Clone)]
83pub struct ProjectSearchOptions {
84 /// Project root the scan walks. Defaults to CWD when the
85 /// trigger function isn't given an explicit one.
86 pub root: PathBuf,
87 /// Whether matches are case-sensitive.
88 pub case_sensitive: bool,
89 /// Cap on the number of files scanned. `None` = unlimited
90 /// (defaults to large; the scan respects `.gitignore`).
91 pub max_files: Option<usize>,
92 /// Cap on hits per file before moving on (per-file
93 /// hit-limit; prevents one huge file from monopolising the
94 /// batch budget).
95 pub max_hits_per_file: usize,
96 /// M.6.3 (2026-06-01): interpret `query` as a `fancy-regex`
97 /// pattern instead of a literal substring. `false` (default)
98 /// keeps the M.6.0 literal-match path for back-compat.
99 /// Case-sensitivity layered on via an injected `(?i)` flag
100 /// when `case_sensitive == false`.
101 pub regex: bool,
102 /// K.4.6 follow-up (2026-06-02): number of context lines to
103 /// show above and below each matched line. Resolved from
104 /// the `search.context_size` typed option at `:search`
105 /// dispatch time. `0` = no context (one excerpt per match
106 /// row); `N > 0` = ±N lines around each match, with
107 /// adjacent clusters merged when ranges overlap or touch.
108 pub context_lines: u32,
109}
110
111impl Default for ProjectSearchOptions {
112 fn default() -> Self {
113 Self {
114 // PR.4: the project containing the working directory, not
115 // the working directory itself. The host overrides this
116 // with a per-buffer root before every `:search`; this is
117 // the answer a direct constructor gets, and it should be
118 // the same KIND of answer rather than a different one.
119 root: lattice_core::project::root_from_cwd().unwrap_or_else(|| PathBuf::from(".")),
120 case_sensitive: false,
121 max_files: None,
122 max_hits_per_file: 100,
123 regex: false,
124 context_lines: 0,
125 }
126 }
127}
128
129/// One file's hits. Returned in `ProjectSearchBatchReady` batches.
130#[derive(Debug, Clone)]
131pub struct FileHits {
132 pub path: PathBuf,
133 /// Each hit's 0-based source row. Sorted ascending.
134 pub rows: Vec<u32>,
135}
136
137/// Status of a scan task.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum SearchStatus {
140 Scanning,
141 Done { total_hits: usize },
142 Failed { reason: String },
143}
144
145/// Service handle for the editor's current working directory.
146/// Registered at boot, updated by `:cd`. Mode-owned handlers
147/// (e.g. search `gr` refresh) look this up via `ActionContext`
148/// to re-resolve the scan root after `:cd`.
149pub type CurrentDirHandle = std::sync::Arc<std::sync::Mutex<Option<std::path::PathBuf>>>;
150
151/// Per-view state owned by `ProjectSearchService`.
152#[derive(Debug)]
153pub struct ProjectSearchState {
154 pub query: String,
155 pub options: ProjectSearchOptions,
156 pub status: SearchStatus,
157 pub total_hits: usize,
158 pub scan_task: Option<tokio::task::JoinHandle<()>>,
159 /// M.6.6 (2026-06-08): cooperative cancellation token. The blocking
160 /// scan loop checks it at each file iteration and exits early when
161 /// it fires. `spawn_blocking` tasks ignore `JoinHandle` abort —
162 /// only this token reaches the blocking thread.
163 ///
164 /// CG.2 (2026-08-08): was a private `Arc<AtomicBool>` flipped by
165 /// the refresh handler for supersede. It is now the editor's
166 /// **foreground** token, armed through `ForegroundCancelHandle`, so
167 /// `<C-g>` reaches a running scan and supersede stops being a
168 /// second, parallel mechanism that cancellation did not know about.
169 /// `ForegroundCancel::arm` cancels its predecessor, which is
170 /// exactly what the refresh handler used to do by hand.
171 pub cancel_token: CancellationToken,
172 /// M.6.1: source `BufferId` → on-disk path. Populated by the
173 /// provider-minor's forwarder as it loads files into the
174 /// view's source map. The mode's `<CR>` handler (M.10.3,
175 /// registered via `ActionHandlerRegistry` from
176 /// `on_activate`) reads this to resolve the excerpt under
177 /// cursor back to a file path.
178 pub source_paths: std::collections::HashMap<BufferId, PathBuf>,
179}
180
181impl ProjectSearchState {
182 /// CG.2: `cancel` is the **armed foreground token** the scan will
183 /// poll — take it as an argument rather than defaulting to
184 /// `never()`, so a spawn site physically cannot register a scan
185 /// that `<C-g>` has no way to reach.
186 pub fn scanning(
187 query: String,
188 options: ProjectSearchOptions,
189 cancel: CancellationToken,
190 ) -> Self {
191 Self {
192 query,
193 options,
194 status: SearchStatus::Scanning,
195 total_hits: 0,
196 scan_task: None,
197 source_paths: std::collections::HashMap::new(),
198 cancel_token: cancel,
199 }
200 }
201}
202
203// ─────────────────────────────────────────────────────────────────
204// Service trait + InMemory impl
205// ─────────────────────────────────────────────────────────────────
206
207/// Per-view provider state lookup. Registered in
208/// `ServiceRegistry` at boot; minor mode + scan-task glue go
209/// through this trait.
210pub trait ProjectSearchService: Send + Sync + std::fmt::Debug {
211 fn state(&self, view: BufferId) -> Option<Arc<RwLock<ProjectSearchState>>>;
212 fn set_state(&self, view: BufferId, state: ProjectSearchState);
213 fn clear(&self, view: BufferId);
214 fn attach_task(&self, view: BufferId, handle: tokio::task::JoinHandle<()>);
215 /// Update the running tally of hits as a scan progresses.
216 fn add_hits(&self, view: BufferId, add: usize);
217 fn set_status(&self, view: BufferId, status: SearchStatus);
218 fn len(&self) -> usize;
219 /// M.6.1: record the on-disk path for a source buffer the
220 /// forwarder just attached to the view. Jump-to-source reads
221 /// this map back to resolve excerpt → path.
222 fn record_source_path(&self, view: BufferId, source: BufferId, path: PathBuf);
223 /// M.6.1: look up the path for a source buffer (used by
224 /// jump-to-source after finding the excerpt under cursor).
225 fn source_path(&self, view: BufferId, source: BufferId) -> Option<PathBuf>;
226 /// M.6.2 (2026-06-01): reverse lookup — find an existing
227 /// source buffer for a path inside a view. Forwarder uses
228 /// this to dedup file loads across batches: a file with
229 /// hits split across two batches reuses the same source
230 /// buffer instead of spawning a second `RopeDocumentHandle`.
231 fn find_source_for_path(&self, view: BufferId, path: &Path) -> Option<BufferId>;
232}
233
234pub type ProjectSearchServiceHandle = Arc<dyn ProjectSearchService>;
235
236/// Default in-memory `ProjectSearchService`.
237#[derive(Debug, Default)]
238pub struct InMemoryProjectSearchService {
239 inner: RwLock<std::collections::HashMap<BufferId, Arc<RwLock<ProjectSearchState>>>>,
240}
241
242impl InMemoryProjectSearchService {
243 pub fn new() -> Self {
244 Self::default()
245 }
246 pub fn handle() -> ProjectSearchServiceHandle {
247 Arc::new(Self::new())
248 }
249}
250
251impl ProjectSearchService for InMemoryProjectSearchService {
252 fn state(&self, view: BufferId) -> Option<Arc<RwLock<ProjectSearchState>>> {
253 self.inner.read().ok()?.get(&view).cloned()
254 }
255 fn set_state(&self, view: BufferId, state: ProjectSearchState) {
256 if let Ok(mut map) = self.inner.write() {
257 map.insert(view, Arc::new(RwLock::new(state)));
258 }
259 }
260 fn clear(&self, view: BufferId) {
261 if let Ok(mut map) = self.inner.write()
262 && let Some(state) = map.remove(&view)
263 && let Ok(mut s) = state.write()
264 && let Some(h) = s.scan_task.take()
265 {
266 h.abort();
267 }
268 }
269 fn attach_task(&self, view: BufferId, handle: tokio::task::JoinHandle<()>) {
270 let Some(state) = self.state(view) else {
271 handle.abort();
272 return;
273 };
274 if let Ok(mut s) = state.write() {
275 if let Some(prior) = s.scan_task.take() {
276 prior.abort();
277 }
278 s.scan_task = Some(handle);
279 }
280 }
281 fn add_hits(&self, view: BufferId, add: usize) {
282 if let Some(state) = self.state(view)
283 && let Ok(mut s) = state.write()
284 {
285 s.total_hits = s.total_hits.saturating_add(add);
286 }
287 }
288 fn set_status(&self, view: BufferId, status: SearchStatus) {
289 if let Some(state) = self.state(view)
290 && let Ok(mut s) = state.write()
291 {
292 s.status = status;
293 }
294 }
295 fn len(&self) -> usize {
296 self.inner.read().map(|m| m.len()).unwrap_or(0)
297 }
298 fn record_source_path(&self, view: BufferId, source: BufferId, path: PathBuf) {
299 if let Some(state) = self.state(view)
300 && let Ok(mut s) = state.write()
301 {
302 s.source_paths.insert(source, path);
303 }
304 }
305 fn source_path(&self, view: BufferId, source: BufferId) -> Option<PathBuf> {
306 self.state(view)?
307 .read()
308 .ok()?
309 .source_paths
310 .get(&source)
311 .cloned()
312 }
313 fn find_source_for_path(&self, view: BufferId, path: &Path) -> Option<BufferId> {
314 let state = self.state(view)?;
315 let guard = state.read().ok()?;
316 guard
317 .source_paths
318 .iter()
319 .find(|(_, p)| p.as_path() == path)
320 .map(|(id, _)| *id)
321 }
322}
323
324// ─────────────────────────────────────────────────────────────────
325// Typed events
326// ─────────────────────────────────────────────────────────────────
327
328#[derive(Debug, Clone)]
329pub struct ProjectSearchBatchReady {
330 pub view: BufferId,
331 pub files: Vec<FileHits>,
332 /// K.4.6 follow-up (2026-06-02): per-batch context-lines
333 /// value resolved from `search.context_size` at scan
334 /// dispatch time. The forwarder reads this off each batch
335 /// (it isn't part of the forwarder's persistent state)
336 /// because the forwarder runs in the mode's activation
337 /// scope, not the scan's. Mirroring the option onto each
338 /// batch is cheaper than wiring a side channel for one
339 /// `u32` and keeps each batch self-describing.
340 pub context_lines: u32,
341}
342
343lattice_protocol::register_event!(
344 ProjectSearchBatchReady,
345 "project-search.batch-ready",
346 "One batch of file hits from a running project-search scan.",
347 "lattice-multibuffer",
348);
349
350#[derive(Debug, Clone)]
351pub struct ProjectSearchCompleted {
352 pub view: BufferId,
353 pub total_hits: usize,
354 pub files_scanned: usize,
355}
356
357lattice_protocol::register_event!(
358 ProjectSearchCompleted,
359 "project-search.completed",
360 "A project-search scan finished. Carries totals for the headerline.",
361 "lattice-multibuffer",
362);
363
364#[derive(Debug, Clone)]
365pub struct ProjectSearchRefreshed {
366 pub view: BufferId,
367 pub new_query: String,
368}
369
370lattice_protocol::register_event!(
371 ProjectSearchRefreshed,
372 "project-search.refreshed",
373 "User refreshed a project-search view's query.",
374 "lattice-multibuffer",
375);
376
377#[derive(Debug, Clone)]
378pub struct ProjectSearchProgressUpdated {
379 pub view: BufferId,
380 pub files_scanned: usize,
381}
382
383lattice_protocol::register_event!(
384 ProjectSearchProgressUpdated,
385 "project-search.progress-updated",
386 "Mid-scan progress update for headerline status.",
387 "lattice-multibuffer",
388);
389
390// ─────────────────────────────────────────────────────────────────
391// Provider-minor mode
392// ─────────────────────────────────────────────────────────────────
393
394/// `project-search-mode` — provider-minor for
395/// project-search views. Contributes `ReadOnly = true` in M.6.0
396/// (wgrep-style editable results land in a follow-up).
397pub struct ProjectSearchMode;
398
399impl ProjectSearchMode {
400 pub fn mode_id() -> ModeId {
401 ModeId::new("project-search-mode")
402 }
403}
404
405pub struct ProjectSearchModeGuard {
406 forwarder: Option<tokio::task::JoinHandle<()>>,
407 subs: Vec<lattice_runtime::SubscriptionId>,
408 bus: Arc<EventBus>,
409 /// M.10.3 (2026-06-03): RAII tokens for action-handler
410 /// registrations made in `on_activate`. Dropping the Guard
411 /// drops these, which in turn unregister the closures from
412 /// `ActionHandlerRegistry`. Currently: `<CR>` jump-to-source.
413 /// M.10.5 will add `gr` refresh to this Vec.
414 _action_handler_registrations: Vec<ActionHandlerRegistration>,
415}
416
417impl Drop for ProjectSearchModeGuard {
418 fn drop(&mut self) {
419 if let Some(h) = self.forwarder.take() {
420 h.abort();
421 }
422 for id in self.subs.drain(..) {
423 let _ = self.bus.unsubscribe(id);
424 }
425 // `_action_handler_registrations` drops in field order;
426 // each registration's `Drop` impl unregisters its handler
427 // from the ActionHandlerRegistry. No explicit work
428 // needed here.
429 }
430}
431
432impl Mode for ProjectSearchMode {
433 type Guard = ProjectSearchModeGuard;
434
435 fn id(&self) -> ModeId {
436 Self::mode_id()
437 }
438 fn kind(&self) -> ModeKind {
439 ModeKind::Minor
440 }
441 fn options(&self) -> OptionOverrideSet {
442 // 2026-06-02: search-result multibuffer is EDITABLE. The
443 // M.3 substrate (MultibufferDocumentHandle::apply_edit)
444 // translates composed-coordinate edits to source coords
445 // and forwards to the source documents — the user typing
446 // in the multibuffer refactors every matched file
447 // in-place. Differentiating UX vs vim quickfix
448 // (read-only) and consistent with Zed-style
449 // "search-and-replace across project" workflow. The
450 // major mode already dropped ReadOnly in M.3; this
451 // minor previously layered `ReadOnly = true` for the
452 // M.6.0 read-only milestone. Dropped now per
453 // paramount-#2 — substrate supports it, honor the
454 // substrate's intent.
455 OptionOverrideSet::new()
456 }
457 fn required_capabilities(&self) -> CapabilitySet {
458 CapabilitySet::empty()
459 }
460 /// K.2.5 (2026-06-02): action chord bindings for
461 /// project-search views — `<CR>` jumps to the source file
462 /// and `gr` re-runs the scan. Resolved at host translation
463 /// time via `CommandRegistry` against the action names
464 /// registered by `lattice-host::actions::populate`.
465 /// RV.2 (2026-08-10): no keymap of its own any more.
466 ///
467 /// `gr` was this mode's only chord, and it now lives once on
468 /// `refreshable-view-mode` — pulled in by the implies cascade
469 /// because [`Self::refresh_action`] returns `Some`. `<CR>`
470 /// navigation still comes from `MultibufferMode`'s generic
471 /// `action:multibuffer-jump-to-source` handler.
472 fn refresh_action(&self) -> Option<&'static str> {
473 Some("action:search-refresh")
474 }
475
476 /// OA.4b: this view folds by blocks, so `<Tab>` / `<S-Tab>` come from the
477 /// shared `foldable-view-mode`. Nothing special to do on a block, so it
478 /// names the generic body.
479 fn fold_toggle_action(&self) -> Option<&'static str> {
480 Some(lattice_mode::FOLD_TOGGLE_DEFAULT_ACTION)
481 }
482
483 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
484 Box::pin(async move {
485 // ModeContext::buffer_id() returns lattice_protocol::BufferId;
486 // registry handles + events use lattice_core::BufferId. Convert.
487 let proto_view_id = ctx.buffer_id();
488 let view_id = lattice_core::BufferId(proto_view_id.raw() as u32);
489 let mb_registry_arc = ctx.service::<MultibufferRegistryHandle>().ok_or_else(|| {
490 lattice_mode::ModeActivationError::MissingCapability {
491 mode: ProjectSearchMode::mode_id(),
492 missing: CapabilitySet::empty(),
493 }
494 })?;
495 // Unwrap the outer Arc that `ServiceRegistry::get`
496 // wraps the handle in.
497 let mb_registry: MultibufferRegistryHandle = (*mb_registry_arc).clone();
498 let bus = ctx.events_handle();
499
500 let (batch_tx, mut batch_rx) = mpsc::unbounded_channel::<ProjectSearchBatchReady>();
501 let (done_tx, mut done_rx) = mpsc::unbounded_channel::<ProjectSearchCompleted>();
502 let (progress_tx, mut progress_rx) =
503 mpsc::unbounded_channel::<ProjectSearchProgressUpdated>();
504
505 let subs = vec![
506 bus.subscribe_typed::<ProjectSearchBatchReady>(batch_tx),
507 bus.subscribe_typed::<ProjectSearchCompleted>(done_tx),
508 bus.subscribe_typed::<ProjectSearchProgressUpdated>(progress_tx),
509 ];
510
511 // Pull the search service so the forwarder can record
512 // source-path mappings as it loads files. Provider-
513 // minor activation runs in a context where the service
514 // is registered; we tolerate it being missing (test
515 // paths) by skipping the record.
516 let search_svc_arc = ctx.service::<ProjectSearchServiceHandle>();
517
518 // M.10.5 (2026-06-03): register `gr` refresh handler.
519 // `<CR>` jump-to-source is handled by MultibufferMode's
520 // generic action:multibuffer-jump-to-source handler;
521 // no project-search-specific handler needed.
522 let mut action_registrations: Vec<ActionHandlerRegistration> = Vec::new();
523 if let (Some(cmd_registry_arc), Some(action_handlers_arc)) = (
524 ctx.service::<CommandRegistryHandle>(),
525 ctx.service::<ActionHandlerRegistryHandle>(),
526 ) {
527 let cmd_registry_snapshot = cmd_registry_arc.load();
528 let cmd_registry: &CommandRegistry = &cmd_registry_snapshot;
529 let action_handlers: ActionHandlerRegistryHandle = (*action_handlers_arc).clone();
530
531 // M.10.5 (2026-06-03): register `gr` refresh handler.
532 // Pre-M.10.5 the chord routed through
533 // `AppEffect::SearchRefresh` → `Action::SearchRefresh`
534 // → `Editor::do_search_refresh` in
535 // `lattice-host::dispatch`. Now mode-owned: the
536 // handler closure captures view_id + the
537 // multibuffer registry + the search service +
538 // the event bus and performs the refresh
539 // in-place (clear excerpts, reset headerline,
540 // spawn fresh scan task, publish
541 // `ProjectSearchRefreshed`). Returns `None` —
542 // no Effect needed since the work is done
543 // synchronously inside the closure.
544 if let Some(refresh_command_id) = cmd_registry.id_by_name("action:search-refresh") {
545 let mb_registry_for_refresh = mb_registry.clone();
546 let search_svc_for_refresh: Option<ProjectSearchServiceHandle> =
547 search_svc_arc.as_ref().map(|s| (**s).clone());
548 let bus_for_refresh = bus.clone();
549 let view_id_for_refresh = view_id;
550 let handler: lattice_mode::ActionHandler = Arc::new(
551 move |ctx: &ActionContext<'_>| -> Option<lattice_grammar::Effect> {
552 // Tolerate missing service (test
553 // harness without boot wiring).
554 let search_svc = search_svc_for_refresh.as_ref()?;
555 let state = search_svc.state(view_id_for_refresh)?;
556 let (query, mut options) = {
557 let s = state.read().ok()?;
558 (s.query.clone(), s.options.clone())
559 };
560 // Re-resolve the scan root from the editor's
561 // current working directory so `gr` picks up
562 // `:cd` changes.
563 if let Some(current_dir_handle) = ctx.services.get::<CurrentDirHandle>()
564 && let Ok(dir) = current_dir_handle.lock()
565 && let Some(ref current_dir) = *dir
566 {
567 options.root = current_dir.clone();
568 }
569 let view = mb_registry_for_refresh.handle(view_id_for_refresh)?;
570 // CG.2: the prior scan is cancelled by
571 // `arm()` below — superseding and cancelling
572 // are one mechanism now, so there is no
573 // separate flag to flip here.
574 // Clear + reset.
575 view.replace_excerpts(std::collections::HashMap::new(), Vec::new());
576 view.set_headerline(HeaderlineStatus::InProgress {
577 label: format!("Refreshing search \"{query}\""),
578 count: Some(0),
579 emphasis: Some(query.clone()),
580 });
581 // CG.2: arm through the shared foreground
582 // slot, which cancels the scan this one
583 // replaces. A missing service is the test
584 // harness, not a boot failure — degrade to
585 // an uncancellable scan rather than refuse
586 // to refresh.
587 let cancel = ctx
588 .services
589 .get::<lattice_mode::ForegroundCancelHandle>()
590 .map(|fc| fc.arm())
591 .unwrap_or_else(CancellationToken::never);
592 search_svc.set_state(
593 view_id_for_refresh,
594 ProjectSearchState::scanning(
595 query.clone(),
596 options.clone(),
597 cancel.clone(),
598 ),
599 );
600 let task = spawn_scan_task(
601 view_id_for_refresh,
602 query.clone(),
603 options,
604 search_svc.clone(),
605 bus_for_refresh.clone(),
606 cancel,
607 );
608 search_svc.attach_task(view_id_for_refresh, task);
609 // Publish refresh event so any
610 // other subscribers (e.g. headerline
611 // listeners) can react.
612 bus_for_refresh.publish_typed(ProjectSearchRefreshed {
613 view: view_id_for_refresh,
614 new_query: query,
615 });
616 None
617 },
618 );
619 action_registrations
620 .push(action_handlers.register(refresh_command_id, handler));
621 }
622 }
623
624 let mb_for_task = mb_registry.clone();
625 let view_id_for_task = view_id;
626 let search_svc_for_task: Option<ProjectSearchServiceHandle> =
627 search_svc_arc.as_ref().map(|s| (**s).clone());
628 let bus_for_task = bus.clone();
629 // The query, so the streamed headerline labels can name +
630 // accent the term being searched (`multibuffer.status.query`).
631 // Read from the seeded scan state (set before activation);
632 // empty if unavailable → the label degrades to no accent.
633 let query_for_task = search_svc_for_task
634 .as_ref()
635 .and_then(|svc| svc.state(view_id_for_task))
636 .and_then(|st| st.read().ok().map(|s| s.query.clone()))
637 .unwrap_or_default();
638 let forwarder = tokio::spawn(async move {
639 loop {
640 tokio::select! {
641 Some(batch) = batch_rx.recv() => {
642 if batch.view != view_id_for_task { continue; }
643 let Some(view) = mb_for_task.handle(view_id_for_task) else { break; };
644 // M.6.1: load each hit's source file as a
645 // fresh RopeDocumentHandle, add to the
646 // view's source map, append 1-row excerpts.
647 // No dedup across batches in M.6.1.0 — a
648 // file with hits split across batches loads
649 // twice. Documented; M.6.2 adds dedup via
650 // service's source_paths map.
651 let mut hit_count_in_batch = 0usize;
652 for fh in batch.files {
653 let path = fh.path.clone();
654 // M.6.2 (2026-06-01): dedup —
655 // if a previous batch already
656 // loaded this file as a source,
657 // reuse the existing source
658 // buffer instead of spawning a
659 // second one.
660 let existing = search_svc_for_task
661 .as_ref()
662 .and_then(|svc| {
663 svc.find_source_for_path(view_id_for_task, &path)
664 });
665 let source_id = if let Some(existing) = existing {
666 existing
667 } else {
668 let Ok(text_res) = tokio::task::spawn_blocking({
669 let p = path.clone();
670 move || std::fs::read_to_string(&p)
671 })
672 .await
673 else { continue; };
674 let Ok(text) = text_res else { continue; };
675
676 let id = BufferId::next();
677 let document = lattice_core::DocumentBuilder::default()
678 .with_text(&text)
679 .with_path(path.clone())
680 .build();
681 // B3b: source docs in the search view get a
682 // fresh empty registry behind the `ArcSwap`
683 // handle `spawn_document` now expects.
684 let registry =
685 Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()));
686 let handle = spawn_document(id, document, registry);
687 let dyn_handle: Arc<dyn Document> = Arc::new(handle);
688 view.add_source(id, dyn_handle);
689 if let Some(svc) = &search_svc_for_task {
690 svc.record_source_path(
691 view_id_for_task,
692 id,
693 path.clone(),
694 );
695 }
696 id
697 };
698
699 // K.4.6 follow-up v4 (2026-06-02):
700 // emit excerpts driven by
701 // `options.context_lines` (resolved
702 // from the `search.context_size`
703 // typed option, defined by
704 // `ProjectSearchMode`).
705 //
706 // - context_lines == 0 (default):
707 // one single-row excerpt per
708 // matched line (row..=row). The
709 // substrate's `compose_header_rows`
710 // (lib.rs) dedupes consecutive
711 // same-source excerpts so the user
712 // sees ONE header per file + one
713 // row per match.
714 //
715 // - context_lines > 0 (e.g.
716 // `:set search.context_size=3`):
717 // one excerpt per hit cluster,
718 // each hit expanded to ±N context
719 // lines, adjacent clusters merged
720 // when ranges overlap or touch.
721 // Mirrors grep `-C N`.
722 let context_lines = batch.context_lines;
723 // MH.A4: the per-file hit count for the
724 // `· N matches` badge. `scan_file`
725 // collects every matched row for a file
726 // in ONE call and emits exactly one
727 // `FileHits` per file per scan, so a
728 // file's hits never split across batches
729 // — `fh.rows.len()` is the file's
730 // complete count. (The M.6.2 dedup only
731 // reuses an already-loaded source on a
732 // re-scan; within a single scan each
733 // file appears once.)
734 let match_count = u32::try_from(fh.rows.len()).ok();
735 // MH.A4: build the header carrying path +
736 // match_count. `compose_header_rows`
737 // dedups consecutive same-source excerpts
738 // and renders the FIRST excerpt's header,
739 // so attach the rich header to every
740 // excerpt of this file — the first one
741 // (which carries the badge) is the one
742 // rendered.
743 let make_header =
744 || search_excerpt_header(&path, match_count);
745 let excerpts: Vec<Excerpt> = if fh.rows.is_empty() {
746 Vec::new()
747 } else if context_lines == 0 {
748 fh.rows
749 .iter()
750 .map(|&row| {
751 Excerpt::new(source_id, row, row)
752 .with_header(make_header())
753 })
754 .collect()
755 } else {
756 let mut sorted_rows = fh.rows.clone();
757 sorted_rows.sort_unstable();
758 let mut clusters: Vec<(u32, u32)> = Vec::new();
759 for &row in &sorted_rows {
760 let start = row.saturating_sub(context_lines);
761 let end = row.saturating_add(context_lines);
762 match clusters.last_mut() {
763 // Merge if the new
764 // cluster's start touches
765 // or overlaps the previous
766 // cluster's end (+1 =
767 // "touches, no gap").
768 Some(last) if start <= last.1.saturating_add(1) => {
769 last.1 = last.1.max(end);
770 }
771 _ => clusters.push((start, end)),
772 }
773 }
774 clusters
775 .into_iter()
776 .map(|(start, end)| {
777 Excerpt::new(source_id, start, end)
778 .with_header(make_header())
779 })
780 .collect()
781 };
782 hit_count_in_batch += fh.rows.len();
783 view.append_excerpts(excerpts);
784 }
785 bus_for_task.publish_typed(crate::events::MultibufferExcerptsReady {
786 view: view_id_for_task,
787 });
788
789 view.set_headerline(HeaderlineStatus::InProgress {
790 label: format!("Searching \"{query_for_task}\""),
791 count: Some(view.excerpt_count()),
792 emphasis: Some(query_for_task.clone()),
793 });
794 let _ = hit_count_in_batch;
795 }
796 Some(prog) = progress_rx.recv() => {
797 if prog.view != view_id_for_task { continue; }
798 let Some(view) = mb_for_task.handle(view_id_for_task) else { break; };
799 view.set_headerline(HeaderlineStatus::InProgress {
800 label: format!(
801 "Searching \"{}\" ({} files)",
802 query_for_task, prog.files_scanned
803 ),
804 count: Some(view.excerpt_count()),
805 emphasis: Some(query_for_task.clone()),
806 });
807 }
808 Some(done) = done_rx.recv() => {
809 if done.view != view_id_for_task { continue; }
810 let Some(view) = mb_for_task.handle(view_id_for_task) else { break; };
811 view.set_headerline(HeaderlineStatus::Complete {
812 summary: format!(
813 "\"{}\" — {} hit(s) in {} files",
814 query_for_task, done.total_hits, done.files_scanned,
815 ),
816 emphasis: Some(query_for_task.clone()),
817 });
818 // M.10.5 bug fix (2026-06-03): do NOT
819 // break here. Pre-fix the forwarder
820 // exited on Done, so any subsequent
821 // `gr` refresh spawned a new scan but
822 // had no subscriber for its batches —
823 // the buffer stayed blank forever.
824 // Now the forwarder stays subscribed
825 // through refresh cycles; the only
826 // exit path is via the mode's Guard
827 // drop (deactivation), which aborts
828 // this task. `continue` re-enters
829 // `select!` for the next batch /
830 // progress / done event from a
831 // future scan.
832 continue;
833 }
834 else => break,
835 }
836 }
837 });
838
839 Ok(ProjectSearchModeGuard {
840 forwarder: Some(forwarder),
841 subs,
842 bus,
843 _action_handler_registrations: action_registrations,
844 })
845 })
846 }
847}
848
849// ─────────────────────────────────────────────────────────────────
850// Public trigger + scan task
851// ─────────────────────────────────────────────────────────────────
852
853/// Open a project-search multibuffer view for `query` under
854/// `options.root`. Returns the view's BufferId immediately;
855/// scan runs on a spawned tokio task and streams results via
856/// typed events.
857///
858/// Returns `None` when `ProjectSearchService` or `EventBus`
859/// isn't registered (boot path didn't wire the provider) —
860/// caller logs + recovers.
861pub fn project_search(
862 activator: &mut dyn ModeActivator,
863 query: String,
864 options: ProjectSearchOptions,
865 registry: lattice_grammar::CommandRegistryHandle,
866 // K.4.7 (2026-06-07): when `Some`, passed to
867 // `create_multibuffer_view` so per-source SyntaxHandles are
868 // created as hits arrive via `add_source`.
869 lang_registry: Option<Arc<lattice_syntax::LangRegistry>>,
870) -> Option<BufferId> {
871 let services = activator.services();
872 // `services.get::<T>()` wraps the registered value in an
873 // outer Arc; our service type is itself
874 // `Arc<dyn ProjectSearchService>`, so we get `Arc<Arc<…>>` —
875 // unwrap once.
876 let search_svc_outer = services.get::<ProjectSearchServiceHandle>()?;
877 let search_svc: ProjectSearchServiceHandle = (*search_svc_outer).clone();
878 // EventBus is registered as `Arc<EventBus>` in
879 // `editor_boot.rs` (`s.register(event_bus.clone())` where
880 // `event_bus: Arc<EventBus>`). Lookup therefore queries
881 // `Arc<EventBus>` and unwraps one Arc layer to get a usable
882 // `Arc<EventBus>` — same shape as the `ProjectSearchServiceHandle`
883 // unwrap above. Earlier sites that queried `EventBus`
884 // directly silently returned None.
885 let events_outer = services.get::<Arc<EventBus>>()?;
886 let events: Arc<EventBus> = (*events_outer).clone();
887
888 let view_id = create_multibuffer_view(
889 activator,
890 std::collections::HashMap::new(),
891 Vec::new(),
892 Some(format!("*search:{query}*")),
893 BufferFlags::default(),
894 registry,
895 lang_registry,
896 // AF.1: search excerpts arrive grouped by file and every one carries
897 // its path as a header, so a file IS a contiguous run — the default.
898 crate::FoldGrouping::SourceFile,
899 );
900
901 // CG.2: arm through the shared foreground slot so `<C-g>` reaches
902 // this scan. Also supersedes whatever was running, which is what
903 // makes a second `:search` abandon the first.
904 let cancel = services
905 .get::<lattice_mode::ForegroundCancelHandle>()
906 .map(|fc| fc.arm())
907 .unwrap_or_else(CancellationToken::never);
908 search_svc.set_state(
909 view_id,
910 ProjectSearchState::scanning(query.clone(), options.clone(), cancel.clone()),
911 );
912
913 if let Some(mb_reg) = services.get::<MultibufferRegistryHandle>()
914 && let Some(view) = mb_reg.handle(view_id)
915 {
916 view.set_headerline(HeaderlineStatus::InProgress {
917 label: format!("Searching \"{query}\""),
918 count: Some(0),
919 emphasis: Some(query.clone()),
920 });
921 }
922
923 // The root this scan walked. A results view has no path, so without it
924 // `:files` from inside the results resolves against the working directory
925 // rather than the project that was searched.
926 activator.set_buffer_scope_dir(view_id, options.root.clone());
927
928 activator.activate_minor_by_id(view_id, ProjectSearchMode::mode_id());
929
930 let task = spawn_scan_task(
931 view_id,
932 query,
933 options,
934 search_svc.clone(),
935 events.clone(),
936 cancel,
937 );
938 search_svc.attach_task(view_id, task);
939
940 Some(view_id)
941}
942
943/// Public so the mode's `gr` refresh handler (M.10.5,
944/// registered via `ActionHandlerRegistry` from `on_activate`)
945/// can respawn after cancelling the prior task.
946pub fn spawn_scan_task(
947 view: BufferId,
948 query: String,
949 options: ProjectSearchOptions,
950 service: ProjectSearchServiceHandle,
951 events: Arc<EventBus>,
952 cancel: CancellationToken,
953) -> tokio::task::JoinHandle<()> {
954 tokio::spawn(async move {
955 run_scan(view, query, options, service, events, cancel).await;
956 })
957}
958
959async fn run_scan(
960 view: BufferId,
961 query: String,
962 options: ProjectSearchOptions,
963 service: ProjectSearchServiceHandle,
964 events: Arc<EventBus>,
965 cancel: CancellationToken,
966) {
967 // M.6.3: compile the matcher up-front. Literal mode stores
968 // the (possibly lowercased) needle; regex mode compiles a
969 // `fancy-regex::Regex` with case-sensitivity baked into the
970 // pattern via an injected `(?i)` flag. Bad regex aborts the
971 // scan early with `SearchStatus::Failed` so the headerline
972 // surfaces the error.
973 let matcher = match build_matcher(&query, &options) {
974 Ok(m) => m,
975 Err(e) => {
976 service.set_status(view, SearchStatus::Failed { reason: e.clone() });
977 events.publish_typed(ProjectSearchCompleted {
978 view,
979 total_hits: 0,
980 files_scanned: 0,
981 });
982 tracing::warn!(error = %e, "project-search: failed to compile matcher");
983 return;
984 }
985 };
986
987 // M.6.X (2026-06-01) UI-discipline retrofit. The editor
988 // actor runs on a `current_thread` tokio runtime
989 // (`editor_actor.rs:575`); `tokio::spawn` inside
990 // `spawn_scan_task` lands on that same single-threaded
991 // runtime. `ignore::Walk` + `std::fs::read_to_string` are
992 // synchronous blocking calls with no `.await` between
993 // syscalls — a `yield_now().await` per file is not nearly
994 // enough to keep the actor's command loop responsive
995 // (paramount-goal-1: keystroke → glyph within the one-frame ceiling, ≤ 8.3 ms at 120 Hz).
996 //
997 // Architectural relocation per `feedback_no_ui_thread_work`:
998 // wrap the entire walk + match + publish loop in
999 // `tokio::task::spawn_blocking` so the work runs on
1000 // tokio's dedicated blocking-task pool, leaving the
1001 // current_thread runtime free for the actor + the
1002 // forwarder. `EventBus::publish_typed` is sync-safe (brief
1003 // `Mutex<Inner>` acquisition; subscribers use unbounded
1004 // mpsc senders, see `subscribe_typed(...)` at line 376),
1005 // so publishes from the blocking task remain correct.
1006 let view_for_task = view;
1007 let service_for_task = service.clone();
1008 let events_for_task = events.clone();
1009 let cancel_for_task = cancel;
1010 let _ = tokio::task::spawn_blocking(move || {
1011 run_scan_blocking(
1012 view_for_task,
1013 matcher,
1014 options,
1015 service_for_task,
1016 events_for_task,
1017 cancel_for_task,
1018 );
1019 })
1020 .await;
1021}
1022
1023/// Synchronous body of the scan. Runs on tokio's blocking
1024/// pool via `spawn_blocking`; never touches the current_thread
1025/// runtime that drives the editor actor.
1026fn run_scan_blocking(
1027 view: BufferId,
1028 matcher: Matcher,
1029 options: ProjectSearchOptions,
1030 service: ProjectSearchServiceHandle,
1031 events: Arc<EventBus>,
1032 cancel: CancellationToken,
1033) {
1034 let walker = ignore::Walk::new(&options.root);
1035 let mut files_scanned: usize = 0;
1036 let mut total_hits: usize = 0;
1037 let mut batch: Vec<FileHits> = Vec::new();
1038 let batch_files = 50usize;
1039 let progress_interval = 200usize;
1040 let max_files = options.max_files.unwrap_or(usize::MAX);
1041
1042 for entry in walker {
1043 if cancel.is_cancelled() {
1044 return;
1045 }
1046 let Ok(entry) = entry else {
1047 continue;
1048 };
1049 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
1050 continue;
1051 }
1052 if files_scanned >= max_files {
1053 break;
1054 }
1055 let path = entry.into_path();
1056 let hits = scan_file(&path, &matcher, options.max_hits_per_file);
1057 files_scanned += 1;
1058
1059 if !hits.is_empty() {
1060 total_hits += hits.len();
1061 batch.push(FileHits { path, rows: hits });
1062 }
1063
1064 if batch.len() >= batch_files {
1065 let add: usize = batch.iter().map(|f| f.rows.len()).sum();
1066 service.add_hits(view, add);
1067 events.publish_typed(ProjectSearchBatchReady {
1068 view,
1069 files: std::mem::take(&mut batch),
1070 context_lines: options.context_lines,
1071 });
1072 }
1073 if files_scanned.is_multiple_of(progress_interval) {
1074 events.publish_typed(ProjectSearchProgressUpdated {
1075 view,
1076 files_scanned,
1077 });
1078 }
1079 }
1080
1081 if !batch.is_empty() {
1082 let add: usize = batch.iter().map(|f| f.rows.len()).sum();
1083 service.add_hits(view, add);
1084 events.publish_typed(ProjectSearchBatchReady {
1085 view,
1086 files: batch,
1087 context_lines: options.context_lines,
1088 });
1089 }
1090 service.set_status(view, SearchStatus::Done { total_hits });
1091 events.publish_typed(ProjectSearchCompleted {
1092 view,
1093 total_hits,
1094 files_scanned,
1095 });
1096}
1097
1098/// M.6.3 (2026-06-01): compiled matcher. Literal mode stores the
1099/// (possibly lowercased) needle; regex mode wraps a compiled
1100/// `fancy-regex::Regex`.
1101#[derive(Debug)]
1102enum Matcher {
1103 Literal {
1104 needle: String,
1105 case_sensitive: bool,
1106 },
1107 Regex(fancy_regex::Regex),
1108}
1109
1110impl Matcher {
1111 fn line_matches(&self, line: &str) -> bool {
1112 match self {
1113 Matcher::Literal {
1114 needle,
1115 case_sensitive: true,
1116 } => line.contains(needle.as_str()),
1117 Matcher::Literal {
1118 needle,
1119 case_sensitive: false,
1120 } => line.to_lowercase().contains(needle.as_str()),
1121 Matcher::Regex(re) => re.is_match(line).unwrap_or(false),
1122 }
1123 }
1124}
1125
1126/// MH.A4: build the rich [`ExcerptHeader`] the search provider attaches
1127/// to each excerpt of a matched file. Carries:
1128/// - `title` — the full path string (display fallback when `path` is
1129/// `None`; the title format is unchanged from M.6).
1130/// - `path` — drives the leading file-type icon + basename/dir split
1131/// in `header_cells`.
1132/// - `match_count` — the `· N matches` badge count.
1133///
1134/// Extracted so the forwarder's excerpt construction and the test
1135/// share one definition. `compose_header_rows` renders only the FIRST
1136/// excerpt of each consecutive same-source run, so every excerpt of a
1137/// file gets the same header (the first is the one shown).
1138fn search_excerpt_header(path: &Path, match_count: Option<u32>) -> ExcerptHeader {
1139 let mut header = ExcerptHeader::new(format!("{}", path.display()));
1140 header.path = Some(path.to_path_buf());
1141 header.match_count = match_count;
1142 header
1143}
1144
1145fn build_matcher(query: &str, options: &ProjectSearchOptions) -> Result<Matcher, String> {
1146 if options.regex {
1147 // Inject `(?i)` when case-insensitive so the compiled
1148 // pattern handles the casing — leaves the user's
1149 // pattern verbatim otherwise.
1150 let pattern = if options.case_sensitive {
1151 query.to_string()
1152 } else {
1153 format!("(?i){query}")
1154 };
1155 fancy_regex::Regex::new(&pattern)
1156 .map(Matcher::Regex)
1157 .map_err(|e| format!("invalid regex `{query}`: {e}"))
1158 } else {
1159 let needle = if options.case_sensitive {
1160 query.to_string()
1161 } else {
1162 query.to_lowercase()
1163 };
1164 Ok(Matcher::Literal {
1165 needle,
1166 case_sensitive: options.case_sensitive,
1167 })
1168 }
1169}
1170
1171fn scan_file(path: &Path, matcher: &Matcher, max_hits: usize) -> Vec<u32> {
1172 let Ok(text) = std::fs::read_to_string(path) else {
1173 return Vec::new();
1174 };
1175 let mut hits = Vec::new();
1176 for (row, line) in text.lines().enumerate() {
1177 if matcher.line_matches(line) {
1178 hits.push(row as u32);
1179 if hits.len() >= max_hits {
1180 break;
1181 }
1182 }
1183 }
1184 hits
1185}
1186
1187// ─────────────────────────────────────────────────────────────────
1188// Boot integration
1189// ─────────────────────────────────────────────────────────────────
1190
1191/// M.6 boot helper — register the provider-minor mode. Mode
1192/// registry is constructed before services in the host's boot
1193/// path, so the two registrations are split for ordering
1194/// flexibility.
1195pub fn register_project_search_mode(mode_registry: &mut ModeRegistry) {
1196 mode_registry
1197 .register(ProjectSearchMode)
1198 .expect("project-search-mode registers without conflict at boot");
1199}
1200
1201/// M.6 boot helper — register the service handle. Call in the
1202/// host's `ServiceRegistry` construction block.
1203pub fn register_project_search_service(services: &mut ServiceRegistry) {
1204 let svc: ProjectSearchServiceHandle = Arc::new(InMemoryProjectSearchService::new());
1205 services.register(svc);
1206}
1207
1208/// K.2.5 (2026-06-02): register the `:search <query>` ex-command.
1209///
1210/// Relocated from `crates/lattice-host/src/multibuffer_keymap.rs::register_search_ex_command`
1211/// as part of the K.2.5 migration. Boot path in `editor_boot.rs`
1212/// calls this directly now; behaviour preserved verbatim.
1213///
1214/// Stashes the query as `Args::String` and routes through
1215/// `AppEffect::SearchTrigger { query }`. M.10.6 (2026-06-03)
1216/// inlined the work into the host's apply_effect arm; the
1217/// previous `Action::SearchTrigger` + `Editor::do_search` hops
1218/// are gone. Empty query is rejected with `BadArgs` — opening
1219/// an empty search view doesn't make sense.
1220pub fn register_search_ex_command(registry: &mut CommandRegistry) {
1221 use lattice_grammar::app_effect::AppEffect;
1222 use lattice_grammar::args::{ArgSpec, Args};
1223 use lattice_grammar::command::LatencyClass;
1224 use lattice_grammar::effect::Effect;
1225 use lattice_grammar::error::CommandError;
1226 use lattice_grammar::registry::{ExCommandSpec, SurfaceForm};
1227
1228 registry.register_ex_command(
1229 "search",
1230 "Project-wide search for the literal query. Opens a multibuffer view that streams results as the scan runs.",
1231 ExCommandSpec {
1232 latency_class: LatencyClass::Reflex,
1233 accepts_bang: false,
1234 accepts_range: false,
1235 parse_args: Arc::new(|s: &str, _bang: bool| {
1236 let trimmed = s.trim();
1237 if trimmed.is_empty() {
1238 return Err(CommandError::BadArgs(
1239 ":search requires a non-empty query".into(),
1240 ));
1241 }
1242 Ok(Args::String(trimmed.to_string()))
1243 }),
1244 apply: Arc::new(|ctx| {
1245 let query = match &ctx.args {
1246 Args::String(s) => s.clone(),
1247 _ => String::new(),
1248 };
1249 Ok(Effect::AppAction(AppEffect::SearchTrigger { query }))
1250 }),
1251 args_schema: vec![ArgSpec::required(
1252 "query",
1253 lattice_grammar::args::ArgKind::String,
1254 "search query",
1255 )],
1256 surface_form: SurfaceForm::Keyword,
1257 },
1258 );
1259}
1260
1261/// Convenience wrapper that calls both helpers. Useful for
1262/// tests that wire mode + service together; production boot
1263/// uses the split helpers.
1264pub fn register_project_search(mode_registry: &mut ModeRegistry, services: &mut ServiceRegistry) {
1265 register_project_search_mode(mode_registry);
1266 register_project_search_service(services);
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271 #![allow(clippy::unwrap_used)]
1272 use super::*;
1273
1274 #[test]
1275 fn service_state_roundtrip() {
1276 let svc = InMemoryProjectSearchService::new();
1277 let view = BufferId(42);
1278 svc.set_state(
1279 view,
1280 ProjectSearchState::scanning(
1281 "foo".into(),
1282 ProjectSearchOptions::default(),
1283 CancellationToken::never(),
1284 ),
1285 );
1286 assert_eq!(svc.len(), 1);
1287 let state = svc.state(view).unwrap();
1288 assert_eq!(state.read().unwrap().query, "foo");
1289
1290 svc.add_hits(view, 5);
1291 assert_eq!(svc.state(view).unwrap().read().unwrap().total_hits, 5);
1292
1293 svc.set_status(view, SearchStatus::Done { total_hits: 5 });
1294 match &svc.state(view).unwrap().read().unwrap().status {
1295 SearchStatus::Done { total_hits } => assert_eq!(*total_hits, 5),
1296 other => panic!("expected Done, got {other:?}"),
1297 }
1298
1299 svc.clear(view);
1300 assert_eq!(svc.len(), 0);
1301 assert!(svc.state(view).is_none());
1302 }
1303
1304 fn literal_matcher(needle: &str, case_sensitive: bool) -> Matcher {
1305 build_matcher(
1306 needle,
1307 &ProjectSearchOptions {
1308 regex: false,
1309 case_sensitive,
1310 ..ProjectSearchOptions::default()
1311 },
1312 )
1313 .unwrap()
1314 }
1315
1316 fn regex_matcher(pattern: &str, case_sensitive: bool) -> Matcher {
1317 build_matcher(
1318 pattern,
1319 &ProjectSearchOptions {
1320 regex: true,
1321 case_sensitive,
1322 ..ProjectSearchOptions::default()
1323 },
1324 )
1325 .unwrap()
1326 }
1327
1328 #[test]
1329 fn scan_file_finds_literal_matches() {
1330 let tmp = tempfile_path();
1331 std::fs::write(&tmp, "alpha\nBetA Foo\ngamma\nfoo bar\n").unwrap();
1332 let hits = scan_file(&tmp, &literal_matcher("foo", false), 100);
1333 assert_eq!(hits, vec![1, 3]);
1334
1335 let hits_case = scan_file(&tmp, &literal_matcher("foo", true), 100);
1336 assert_eq!(hits_case, vec![3]);
1337
1338 std::fs::remove_file(&tmp).ok();
1339 }
1340
1341 #[test]
1342 fn scan_file_respects_max_hits() {
1343 let tmp = tempfile_path();
1344 std::fs::write(&tmp, "foo\nfoo\nfoo\nfoo\n").unwrap();
1345 let hits = scan_file(&tmp, &literal_matcher("foo", true), 2);
1346 assert_eq!(hits, vec![0, 1]);
1347 std::fs::remove_file(&tmp).ok();
1348 }
1349
1350 #[test]
1351 fn scan_file_regex_mode_matches_pattern() {
1352 let tmp = tempfile_path();
1353 std::fs::write(
1354 &tmp,
1355 "TODO: fix\nDONE: nothing\ntodo: lowercase\nFIXME: also\n",
1356 )
1357 .unwrap();
1358
1359 // Case-sensitive regex: only literal "TODO".
1360 let hits = scan_file(&tmp, ®ex_matcher(r"^TODO", true), 100);
1361 assert_eq!(hits, vec![0]);
1362
1363 // Case-insensitive regex: TODO + todo lines match.
1364 let hits_ci = scan_file(&tmp, ®ex_matcher(r"^TODO", false), 100);
1365 assert_eq!(hits_ci, vec![0, 2]);
1366
1367 // Alternation: TODO or FIXME.
1368 let hits_alt = scan_file(&tmp, ®ex_matcher(r"^(TODO|FIXME)", true), 100);
1369 assert_eq!(hits_alt, vec![0, 3]);
1370
1371 std::fs::remove_file(&tmp).ok();
1372 }
1373
1374 #[test]
1375 fn build_matcher_rejects_invalid_regex() {
1376 let err = build_matcher(
1377 "(unclosed",
1378 &ProjectSearchOptions {
1379 regex: true,
1380 ..ProjectSearchOptions::default()
1381 },
1382 )
1383 .unwrap_err();
1384 assert!(err.contains("invalid regex"));
1385 }
1386
1387 #[test]
1388 fn find_source_for_path_reverse_lookup_roundtrips() {
1389 // M.6.2: the dedup hook in the forwarder consults
1390 // `find_source_for_path` before spawning a fresh
1391 // RopeDocumentHandle. Verify the lookup returns the
1392 // first-recorded source for a path.
1393 let svc = InMemoryProjectSearchService::new();
1394 let view = BufferId(1);
1395 svc.set_state(
1396 view,
1397 ProjectSearchState::scanning(
1398 "q".into(),
1399 ProjectSearchOptions::default(),
1400 CancellationToken::never(),
1401 ),
1402 );
1403
1404 let path_a = PathBuf::from("/tmp/a.rs");
1405 let path_b = PathBuf::from("/tmp/b.rs");
1406 let src_a = BufferId(10);
1407 let src_b = BufferId(11);
1408 svc.record_source_path(view, src_a, path_a.clone());
1409 svc.record_source_path(view, src_b, path_b.clone());
1410
1411 assert_eq!(svc.find_source_for_path(view, &path_a), Some(src_a));
1412 assert_eq!(svc.find_source_for_path(view, &path_b), Some(src_b));
1413 assert_eq!(
1414 svc.find_source_for_path(view, &PathBuf::from("/tmp/missing.rs")),
1415 None,
1416 );
1417 // A second view with the same path doesn't see source-a.
1418 assert_eq!(svc.find_source_for_path(BufferId(2), &path_a), None,);
1419 }
1420
1421 #[test]
1422 fn scan_file_missing_returns_empty() {
1423 let hits = scan_file(
1424 Path::new("/tmp/__lattice_definitely_missing__"),
1425 &literal_matcher("x", true),
1426 100,
1427 );
1428 assert!(hits.is_empty());
1429 }
1430
1431 fn tempfile_path() -> PathBuf {
1432 // Unique per call even under parallel test load: a process-wide atomic
1433 // counter guarantees distinct paths where a bare timestamp could collide
1434 // (coarse clock granularity across concurrent threads), which corrupted
1435 // one test's fixture with another's content.
1436 use std::sync::atomic::{AtomicU64, Ordering};
1437 static COUNTER: AtomicU64 = AtomicU64::new(0);
1438 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1439 let mut p = std::env::temp_dir();
1440 p.push(format!(
1441 "lattice-search-test-{}-{}-{}",
1442 std::process::id(),
1443 std::time::SystemTime::now()
1444 .duration_since(std::time::UNIX_EPOCH)
1445 .unwrap()
1446 .as_nanos(),
1447 n
1448 ));
1449 p
1450 }
1451
1452 // ── M.6.6: cooperative cancellation ──────────────────────────
1453
1454 /// A cancelled scan must exit without publishing
1455 /// `ProjectSearchCompleted`. Verifies the token-check at
1456 /// the top of each `walker.next()` iteration fires before
1457 /// any file is processed when the token is pre-set.
1458 #[test]
1459 fn cancelled_scan_exits_without_publishing_completed() {
1460 let rt = tokio::runtime::Builder::new_current_thread()
1461 .enable_all()
1462 .build()
1463 .unwrap();
1464
1465 let events = Arc::new(lattice_runtime::EventBus::new());
1466 let view = BufferId(77);
1467
1468 // Track completions via an mpsc channel.
1469 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<ProjectSearchCompleted>();
1470 events.subscribe_typed(tx);
1471
1472 // Pre-cancel before the task can process any files.
1473 let cancel = CancellationToken::new();
1474 cancel.cancel();
1475
1476 let svc = InMemoryProjectSearchService::handle();
1477 svc.set_state(
1478 view,
1479 ProjectSearchState::scanning(
1480 "x".into(),
1481 ProjectSearchOptions::default(),
1482 cancel.clone(),
1483 ),
1484 );
1485
1486 rt.block_on(async move {
1487 let handle = spawn_scan_task(
1488 view,
1489 "x".into(),
1490 ProjectSearchOptions::default(),
1491 svc,
1492 events,
1493 cancel,
1494 );
1495 handle.await.unwrap();
1496 });
1497
1498 assert!(
1499 rx.try_recv().is_err(),
1500 "cancelled scan must not publish ProjectSearchCompleted"
1501 );
1502 }
1503
1504 /// CG.2: refreshing a running scan supersedes it, and the state
1505 /// carries the replacement.
1506 ///
1507 /// This used to hand-simulate the old mechanism — flip a private
1508 /// `AtomicBool`, install a fresh one. It now goes through
1509 /// `ForegroundCancel::arm`, which is what the production refresh
1510 /// handler calls, so supersede and `<C-g>` are provably the same
1511 /// flag rather than two schemes that happen to agree.
1512 #[tokio::test(flavor = "current_thread")]
1513 async fn refresh_supersedes_the_running_scan_and_state_carries_the_replacement() {
1514 let svc = InMemoryProjectSearchService::handle();
1515 let view = BufferId(88);
1516 let fc = lattice_mode::ForegroundCancel::default();
1517
1518 let old_cancel = fc.arm();
1519 svc.set_state(
1520 view,
1521 ProjectSearchState::scanning(
1522 "x".into(),
1523 ProjectSearchOptions::default(),
1524 old_cancel.clone(),
1525 ),
1526 );
1527
1528 // The refresh: arming the replacement cancels its predecessor.
1529 let new_cancel = fc.arm();
1530 svc.set_state(
1531 view,
1532 ProjectSearchState::scanning(
1533 "x".into(),
1534 ProjectSearchOptions::default(),
1535 new_cancel.clone(),
1536 ),
1537 );
1538
1539 assert!(old_cancel.is_cancelled(), "the superseded scan must stop");
1540 assert!(!new_cancel.is_cancelled(), "the replacement runs");
1541
1542 // And `<C-g>` reaches the REPLACEMENT, not just the original —
1543 // the half-wiring this slice exists to prevent.
1544 fc.cancel();
1545 assert!(new_cancel.is_cancelled());
1546
1547 let held = svc
1548 .state(view)
1549 .and_then(|s| s.read().ok().map(|s| s.cancel_token.clone()))
1550 .expect("state present");
1551 assert!(
1552 held.is_cancelled(),
1553 "the token the STATE carries must be the one that was armed, \
1554 not a default `never()` the scan could never observe"
1555 );
1556 }
1557
1558 // ── MH.A4: search header carries path + match_count ──────────
1559
1560 #[test]
1561 fn search_excerpt_header_sets_path_and_match_count() {
1562 // The provider attaches a header carrying `path` + the
1563 // per-file hit count (`fh.rows.len()`). A file with 3 hits
1564 // yields `path = Some` and `match_count = Some(3)`.
1565 let path = PathBuf::from("src/foo/bar.rs");
1566 let header = search_excerpt_header(&path, Some(3));
1567 assert_eq!(header.path.as_deref(), Some(path.as_path()));
1568 assert_eq!(header.match_count, Some(3));
1569 // Title format unchanged (full path display string).
1570 assert_eq!(header.title, format!("{}", path.display()));
1571 }
1572
1573 #[test]
1574 fn search_header_renders_n_matches_badge() {
1575 // End-to-end: the header the provider builds, run through the
1576 // shared `header_cells` renderer, contains the `N matches`
1577 // badge — proving MH.A2's fields drive MH.A3's already-shipped
1578 // rendering.
1579 let path = PathBuf::from("src/foo/bar.rs");
1580 let header = search_excerpt_header(&path, Some(7));
1581 let cells = crate::header_cells(&header, false, 0xAA, 0xBB, 0xCC);
1582 let rendered: String = cells
1583 .iter()
1584 .filter_map(|c| char::from_u32(c.codepoint))
1585 .collect();
1586 assert!(
1587 rendered.contains("7 matches"),
1588 "rendered header must carry the badge; got {rendered:?}"
1589 );
1590 assert!(
1591 rendered.contains("bar.rs"),
1592 "rendered header must carry the basename; got {rendered:?}"
1593 );
1594 }
1595
1596 /// Drive a REAL scan against a temp corpus and assert the batch's
1597 /// per-file count (`fh.rows.len()`) — the value the provider feeds
1598 /// into `search_excerpt_header`'s `match_count` — matches the
1599 /// file's actual hits. Confirms the per-file-count decision (no
1600 /// split across batches within one scan) on the production path.
1601 #[tokio::test(flavor = "current_thread")]
1602 async fn scan_batch_per_file_count_matches_file_hits() {
1603 // One temp file with exactly 3 matching lines.
1604 let dir = tempfile_path();
1605 std::fs::create_dir_all(&dir).unwrap();
1606 let file = dir.join("hits.txt");
1607 std::fs::write(&file, "needle a\nno match\nneedle b\nneedle c\nplain\n").unwrap();
1608
1609 let events = Arc::new(lattice_runtime::EventBus::new());
1610 let (tx, mut rx) = mpsc::unbounded_channel::<ProjectSearchBatchReady>();
1611 events.subscribe_typed(tx);
1612
1613 let view = BufferId(909);
1614 let options = ProjectSearchOptions {
1615 root: dir.clone(),
1616 case_sensitive: true,
1617 max_files: None,
1618 max_hits_per_file: 100,
1619 regex: false,
1620 context_lines: 0,
1621 };
1622 let svc = InMemoryProjectSearchService::handle();
1623 svc.set_state(
1624 view,
1625 ProjectSearchState::scanning(
1626 "needle".into(),
1627 options.clone(),
1628 CancellationToken::never(),
1629 ),
1630 );
1631
1632 let handle = spawn_scan_task(
1633 view,
1634 "needle".into(),
1635 options,
1636 svc,
1637 events,
1638 CancellationToken::never(),
1639 );
1640 handle.await.unwrap();
1641
1642 let batch = rx.try_recv().expect("a batch with hits was published");
1643 let fh = batch
1644 .files
1645 .iter()
1646 .find(|f| f.path == file)
1647 .expect("the matched file is in the batch");
1648 assert_eq!(fh.rows.len(), 3, "file's complete hit count in one batch");
1649
1650 // The header the provider would build for this file carries the
1651 // count.
1652 let header = search_excerpt_header(&fh.path, u32::try_from(fh.rows.len()).ok());
1653 assert_eq!(header.match_count, Some(3));
1654
1655 let _ = std::fs::remove_dir_all(&dir);
1656 }
1657}