Skip to main content

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, &regex_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, &regex_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, &regex_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}