Skip to main content

lattice_lsp/providers/
references.rs

1//! LR.1 (2026-08-11): the **references multibuffer** — every reference
2//! site as an editable excerpt.
3//!
4//! Design: `docs/dev/architecture/lsp-architecture.md` §17. Slice plan:
5//! `docs/dev/operations/slice-plans/lsp-references-view.md`.
6//!
7//! ## Why a second surface rather than a replacement
8//!
9//! `gr` opens a **picker**, and keeps doing so. That is the right shape
10//! for *go to one of these*, and the muscle memory should not move. It
11//! is the wrong shape for *rename this argument at all fifteen call
12//! sites*, which is what references are mostly for — and that workflow
13//! has no surface at all today.
14//!
15//! So this is a peer, opened by `:lsp-references`, not a change to what
16//! `gr` does.
17//!
18//! ## Shape
19//!
20//! Follows `lattice_multibuffer::providers::problems` rather than
21//! `providers::search`: the LSP layer delivers the location list over
22//! an existing channel, so this composes a *delivered* result and owns
23//! no scan of its own. Each unique file is read into a fresh
24//! `RopeDocumentHandle` and added to the view's source map — the same
25//! source-loading shape both existing providers use, deliberately not a
26//! novel file-reading path.
27//!
28//! ## `gr` in the view means refresh
29//!
30//! [`LspReferencesMode`] declares `refresh_action` and so inherits `gr`
31//! from `refreshable-view-mode` (RV.1). It binds no chord itself: the
32//! chord lives in exactly one place, and a copy here would be the sixth
33//! (`mode-architecture.md` §5.5).
34
35use std::collections::HashMap;
36use std::path::PathBuf;
37use std::sync::Arc;
38
39use lattice_config::OptionOverrideSet;
40use lattice_core::{BufferFlags, BufferId, DocumentBuilder};
41use lattice_grammar::Effect;
42use lattice_grammar::LspRequest;
43use lattice_grammar::{CommandRegistry, CommandRegistryHandle};
44use lattice_mode::{
45    ActionContext, ActionHandler, ActionHandlerContribution, CapabilitySet, Keymap,
46    LifecycleFuture, Mode, ModeActivator, ModeContext, ModeId, ModeKind, ModeRegistry,
47};
48use lattice_multibuffer::view::create_multibuffer_view;
49use lattice_multibuffer::{Excerpt, ExcerptHeader, HeaderlineStatus, MultibufferRegistryHandle};
50use lattice_runtime::{Document, spawn_document};
51use lattice_syntax::LangRegistry;
52use lsp_types::Location;
53
54use crate::actor::uri_to_path;
55
56/// Context lines shown above and below each reference site.
57///
58/// A fixed ±2 window, like `problems`, and for the same reason: a
59/// reference is anchored on a known location, so the excerpt wants
60/// enough surrounding code to orient and no more. (Search's
61/// `search.context_size` is a user option because search hits are
62/// open-ended.)
63const CONTEXT: u32 = 2;
64
65// ─────────────────────────────────────────────────────────────────
66// The origin — what a refresh re-queries
67// ─────────────────────────────────────────────────────────────────
68
69/// Where the references query was issued from.
70///
71/// Kept per-view so `gr` can re-run the *same* query. It must not be
72/// re-derived from the cursor at refresh time: by then the cursor is
73/// inside the multibuffer, and re-querying there would ask about
74/// whatever symbol happens to sit under it — a different question, with
75/// a plausible-looking wrong answer.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ReferencesOrigin {
78    /// The document the query was issued against.
79    pub uri: String,
80    /// 0-based line within that document.
81    pub line: u32,
82    /// 0-based UTF-16 character offset (LSP's own convention).
83    pub character: u32,
84    /// The symbol text, for the headerline. May be empty when the
85    /// word under the cursor could not be determined.
86    pub symbol: String,
87}
88
89/// Per-view state: the origin to re-query, keyed by the view's
90/// `BufferId`, plus a `DocumentId` → `BufferId` index for cleanup.
91///
92/// The second map is not redundant. `Event::DocumentClosed` carries a
93/// `DocumentId`, and the two ids are NOT interchangeable — the
94/// multibuffer registry keeps a separate `remove_by_document_id` for
95/// the same reason. Recording the pair at creation is cheaper than
96/// walking every view on each close.
97#[derive(Debug, Default)]
98pub struct LspReferencesService {
99    views: std::sync::RwLock<HashMap<BufferId, ReferencesOrigin>>,
100    by_document: std::sync::RwLock<HashMap<lattice_protocol::ids::DocumentId, BufferId>>,
101}
102
103impl LspReferencesService {
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    pub fn set_origin(&self, view: BufferId, origin: ReferencesOrigin) {
109        if let Ok(mut w) = self.views.write() {
110            w.insert(view, origin);
111        }
112    }
113
114    /// Record the view's document id so a `DocumentClosed` event can
115    /// find it.
116    pub fn index_document(&self, document: lattice_protocol::ids::DocumentId, view: BufferId) {
117        if let Ok(mut w) = self.by_document.write() {
118            w.insert(document, view);
119        }
120    }
121
122    /// Cleanup entry point for the `DocumentClosed` subscriber.
123    /// Returns `true` when a view was forgotten.
124    pub fn forget_by_document_id(&self, document: lattice_protocol::ids::DocumentId) -> bool {
125        let view = match self.by_document.write() {
126            Ok(mut w) => w.remove(&document),
127            Err(_) => None,
128        };
129        match view {
130            Some(v) => {
131                self.forget(v);
132                true
133            }
134            None => false,
135        }
136    }
137
138    pub fn origin(&self, view: BufferId) -> Option<ReferencesOrigin> {
139        self.views.read().ok()?.get(&view).cloned()
140    }
141
142    /// Drop a view's state.
143    pub fn forget(&self, view: BufferId) {
144        if let Ok(mut w) = self.views.write() {
145            w.remove(&view);
146        }
147        if let Ok(mut w) = self.by_document.write() {
148            w.retain(|_, v| *v != view);
149        }
150    }
151
152    pub fn tracked_views(&self) -> usize {
153        self.views.read().map(|r| r.len()).unwrap_or(0)
154    }
155}
156
157/// Register and look up under THIS alias, never the inner type — the
158/// `ServiceRegistry` keys on `TypeId`, so registering an
159/// `Arc<LspReferencesService>` and asking for `LspReferencesService`
160/// silently returns `None`.
161pub type LspReferencesServiceHandle = Arc<LspReferencesService>;
162
163// ─────────────────────────────────────────────────────────────────
164// LspReferencesMode — identity marker for references views
165// ─────────────────────────────────────────────────────────────────
166
167/// `lsp-references-mode` — the provider-minor activated on a references
168/// view. An identity marker, like `ProblemsMinorMode`: a multibuffer
169/// with this minor active IS a references view.
170///
171/// Editable — no `ReadOnly` override, so edits propagate to the sources
172/// through the standard M.3 pipeline. That is the entire point of the
173/// surface.
174pub struct LspReferencesMode;
175
176impl LspReferencesMode {
177    pub fn mode_id() -> ModeId {
178        ModeId::new("lsp-references-mode")
179    }
180}
181
182pub struct LspReferencesModeGuard;
183
184/// The refresh action this mode declares and handles.
185pub const REFRESH_ACTION: &str = "action:lsp-references-refresh";
186
187/// Returns the host-owned effect; the substrate reads the view's stored
188/// origin rather than the live cursor (see [`ReferencesOrigin`]).
189fn refresh_handler() -> ActionHandler {
190    Arc::new(|_ctx: &ActionContext<'_>| Some(Effect::Lsp(LspRequest::ReferencesViewRefresh)))
191}
192
193impl Mode for LspReferencesMode {
194    type Guard = LspReferencesModeGuard;
195
196    fn id(&self) -> ModeId {
197        Self::mode_id()
198    }
199    fn kind(&self) -> ModeKind {
200        ModeKind::Minor
201    }
202    fn options(&self) -> OptionOverrideSet {
203        OptionOverrideSet::new()
204    }
205    fn required_capabilities(&self) -> CapabilitySet {
206        CapabilitySet::empty()
207    }
208
209    /// No chords of its own. `gr` arrives from `refreshable-view-mode`
210    /// via [`Self::refresh_action`] — see the module docs.
211    fn keymap(&self) -> Keymap {
212        Keymap::default()
213    }
214
215    /// Declaring the target is what pulls in the shared `gr` minor
216    /// through the implies cascade.
217    fn refresh_action(&self) -> Option<&'static str> {
218        Some(REFRESH_ACTION)
219    }
220
221    /// OA.4b: this view folds by blocks, so `<Tab>` / `<S-Tab>` come from the
222    /// shared `foldable-view-mode`. Nothing special to do on a block, so it
223    /// names the generic body.
224    fn fold_toggle_action(&self) -> Option<&'static str> {
225        Some(lattice_mode::FOLD_TOGGLE_DEFAULT_ACTION)
226    }
227
228    /// LR.3: the refresh handler. Mode owns the *decision*; the host
229    /// owns the generic async execution — §16's split, unchanged. The
230    /// handler holds only `&ActionContext` and so could not drive the
231    /// request even if it wanted to.
232    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
233        vec![ActionHandlerContribution {
234            action_name: REFRESH_ACTION,
235            handler: refresh_handler(),
236        }]
237    }
238
239    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
240        Box::pin(async move { Ok(LspReferencesModeGuard) })
241    }
242}
243
244// ─────────────────────────────────────────────────────────────────
245// View construction
246// ─────────────────────────────────────────────────────────────────
247
248/// Header for one reference excerpt: the path plus the 1-based line,
249/// with the path attached so the rich header renderer shows the
250/// file-type icon and basename/dir split.
251fn reference_excerpt_header(path: &std::path::Path, line0: u32) -> ExcerptHeader {
252    let mut header = ExcerptHeader::new(format!("{}", line0.saturating_add(1)));
253    header.path = Some(path.to_path_buf());
254    header
255}
256
257/// Group locations into per-file sources + excerpts.
258///
259/// Returns `(sources, excerpts, n_files)`, or `None` when there is
260/// nothing to show — no locations, or every referenced file unreadable.
261/// Shared by open and refresh so the two cannot drift.
262///
263/// Files appear in first-seen order and locations within a file are
264/// ordered by line, which gives a stable layout across refreshes.
265/// A file that fails to read is logged and skipped: one unreadable
266/// path must not cost the user every other reference.
267#[allow(clippy::type_complexity)]
268pub fn build_reference_excerpts(
269    locations: &[Location],
270) -> Option<(HashMap<BufferId, Arc<dyn Document>>, Vec<Excerpt>, usize)> {
271    if locations.is_empty() {
272        return None;
273    }
274
275    let mut file_order: Vec<PathBuf> = Vec::new();
276    let mut by_file: HashMap<PathBuf, Vec<u32>> = HashMap::new();
277    for loc in locations {
278        let Some(path) = uri_to_path(&loc.uri) else {
279            tracing::debug!(
280                uri = %loc.uri.as_str(),
281                "references: location has no file path; skipping"
282            );
283            continue;
284        };
285        if !by_file.contains_key(&path) {
286            file_order.push(path.clone());
287        }
288        by_file.entry(path).or_default().push(loc.range.start.line);
289    }
290
291    let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
292    let mut excerpts: Vec<Excerpt> = Vec::new();
293    let mut n_files = 0usize;
294
295    for path in &file_order {
296        let text = match std::fs::read_to_string(path) {
297            Ok(t) => t,
298            Err(e) => {
299                tracing::warn!(
300                    path = %path.display(),
301                    error = %e,
302                    "references: source file unreadable; skipping its sites",
303                );
304                continue;
305            }
306        };
307        let last_line = (text.lines().count() as u32).saturating_sub(1);
308
309        let source_id = BufferId::next();
310        let document = DocumentBuilder::default()
311            .with_text(&text)
312            .with_path(path.clone())
313            .build();
314        let source_registry = Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()));
315        let handle = spawn_document(source_id, document, source_registry);
316        let dyn_handle: Arc<dyn Document> = Arc::new(handle);
317        sources.insert(source_id, dyn_handle);
318        n_files += 1;
319
320        let mut lines = by_file.remove(path).unwrap_or_default();
321        lines.sort_unstable();
322        // The same line can host several references (`foo(foo)`); one
323        // excerpt per line is what the user wants to see and edit.
324        lines.dedup();
325        for line0 in lines {
326            let line = line0.min(last_line);
327            let start = line.saturating_sub(CONTEXT);
328            let end = (line + CONTEXT).min(last_line);
329            let header = reference_excerpt_header(path, line);
330            excerpts.push(Excerpt::new(source_id, start, end).with_header(header));
331        }
332    }
333
334    if excerpts.is_empty() {
335        return None;
336    }
337    Some((sources, excerpts, n_files))
338}
339
340/// Set the sticky headerline: the site/file count and the symbol.
341fn set_references_headerline(
342    activator: &mut dyn ModeActivator,
343    view: BufferId,
344    symbol: &str,
345    n_sites: usize,
346    n_files: usize,
347) {
348    if let Some(reg) = activator.services().get::<MultibufferRegistryHandle>()
349        && let Some(handle) = reg.handle(view)
350    {
351        let label = if symbol.is_empty() {
352            String::new()
353        } else {
354            format!(" for `{symbol}`")
355        };
356        handle.set_headerline(HeaderlineStatus::Complete {
357            summary: format!("[references{label}] {n_sites} in {n_files} files"),
358            emphasis: None,
359        });
360    }
361}
362
363/// Open a references multibuffer over `locations`.
364///
365/// Returns the view's `BufferId`, or `None` when there is nothing to
366/// show — the caller echoes rather than opening an empty view.
367///
368/// `origin` is stored per-view so `gr` re-queries the symbol the search
369/// started from, not whatever the multibuffer cursor later lands on.
370pub fn create_references_view(
371    activator: &mut dyn ModeActivator,
372    locations: &[Location],
373    origin: ReferencesOrigin,
374    registry: CommandRegistryHandle,
375    lang_registry: Option<Arc<LangRegistry>>,
376) -> Option<BufferId> {
377    let (sources, excerpts, n_files) = build_reference_excerpts(locations)?;
378    let n_sites = excerpts.len();
379
380    let view = create_multibuffer_view(
381        activator,
382        sources,
383        excerpts,
384        Some("*references*".to_string()),
385        BufferFlags::default(),
386        registry,
387        lang_registry,
388        // AF.1: references arrive grouped by file and each carries its path as
389        // a header, so a file is a contiguous run — the default.
390        lattice_multibuffer::FoldGrouping::SourceFile,
391    );
392
393    if let Some(svc) = activator.services().get::<LspReferencesServiceHandle>() {
394        svc.set_origin(view, origin.clone());
395        // Index by document id so `DocumentClosed` can find this view.
396        if let Some(reg) = activator.services().get::<MultibufferRegistryHandle>()
397            && let Some(handle) = reg.handle(view)
398        {
399            svc.index_document(handle.document_id(), view);
400        }
401    } else {
402        // Not fatal: the view is still usable, `gr` simply has nothing
403        // to re-query. Loud at debug because it means boot wiring is
404        // missing, not that the user did anything.
405        tracing::debug!("references: service not registered; refresh will be unavailable");
406    }
407
408    set_references_headerline(activator, view, &origin.symbol, n_sites, n_files);
409    activator.activate_minor_by_id(view, LspReferencesMode::mode_id());
410    Some(view)
411}
412
413/// LR.3 (2026-08-11): rebuild an existing references view from a fresh
414/// result set, in place.
415///
416/// In place is the point: [`create_references_view`] mints a new
417/// `BufferId` every call, so a refresh that re-opened would strand the
418/// view the user pressed `gr` in and add a second `*references*`
419/// beside it — the mistake `*problems*` refresh had to avoid too.
420///
421/// Returns the new site count, or `None` when the view is unknown or
422/// the fresh results yield nothing to show — in which case the view is
423/// left exactly as it was. A refresh must never blank the buffer the
424/// user is reading.
425pub fn refresh_references_view(
426    activator: &mut dyn ModeActivator,
427    view: BufferId,
428    locations: &[Location],
429) -> Option<usize> {
430    let (sources, excerpts, n_files) = build_reference_excerpts(locations)?;
431    let n_sites = excerpts.len();
432
433    let reg = activator.services().get::<MultibufferRegistryHandle>()?;
434    let handle = reg.handle(view)?;
435    handle.replace_excerpts(sources, excerpts);
436    drop(handle);
437
438    let symbol = activator
439        .services()
440        .get::<LspReferencesServiceHandle>()
441        .and_then(|svc| svc.origin(view))
442        .map(|o| o.symbol)
443        .unwrap_or_default();
444    set_references_headerline(activator, view, &symbol, n_sites, n_files);
445    Some(n_sites)
446}
447
448// ─────────────────────────────────────────────────────────────────
449// Boot integration
450// ─────────────────────────────────────────────────────────────────
451
452/// Register `action:lsp-references-refresh` so the mode's declared
453/// refresh target resolves at boot.
454///
455/// The `apply` is a dead `Effect::None`: the mode's `action_handlers`
456/// closure intercepts before the grammar Action gate. It exists so the
457/// `CommandId` resolves — the `repl-mode` shape.
458pub fn register_references_actions(registry: &mut CommandRegistry) {
459    registry.register_action(
460        REFRESH_ACTION,
461        "lsp-references-mode `gr`: re-run the query at the view's origin and rebuild in place.",
462        lattice_grammar::registry::ActionSpec {
463            apply: Arc::new(|_| Ok(Effect::None)),
464            args_schema: vec![],
465        },
466    );
467}
468
469/// Register the provider-minor mode.
470pub fn register_references_mode(modes: &mut ModeRegistry) {
471    modes
472        .register(LspReferencesMode)
473        .expect("lsp-references-mode registers without conflict at boot");
474}
475
476#[cfg(test)]
477mod tests {
478    #![allow(clippy::unwrap_used)]
479    use super::*;
480    use lsp_types::{Position, Range, Uri};
481
482    fn loc(uri: &str, line: u32) -> Location {
483        Location {
484            uri: uri.parse::<Uri>().unwrap(),
485            range: Range {
486                start: Position { line, character: 0 },
487                end: Position { line, character: 3 },
488            },
489        }
490    }
491
492    struct TempTree {
493        dir: PathBuf,
494    }
495
496    impl TempTree {
497        fn new(tag: &str) -> Self {
498            let dir =
499                std::env::temp_dir().join(format!("lattice-refs-{tag}-{}", std::process::id()));
500            std::fs::create_dir_all(&dir).unwrap();
501            Self { dir }
502        }
503        fn write(&self, name: &str, body: &str) -> PathBuf {
504            let p = self.dir.join(name);
505            std::fs::write(&p, body).unwrap();
506            p
507        }
508        fn uri(&self, name: &str) -> String {
509            // Through the real converter: gluing `file://` onto a path is
510            // only a URI where paths start with `/`.
511            crate::actor::uri_from_path(&self.dir.join(name))
512                .as_str()
513                .to_string()
514        }
515    }
516
517    impl Drop for TempTree {
518        fn drop(&mut self) {
519            let _ = std::fs::remove_dir_all(&self.dir);
520        }
521    }
522
523    const EIGHT: &str = "l0\nl1\nl2\nl3\nl4\nl5\nl6\nl7\n";
524
525    #[test]
526    fn mode_declares_a_refresh_and_binds_no_chord() {
527        let m = LspReferencesMode;
528        assert_eq!(m.kind(), ModeKind::Minor);
529        assert_eq!(m.refresh_action(), Some("action:lsp-references-refresh"));
530        // `gr` comes from the shared minor. A chord here would be the
531        // sixth copy — the thing RV.2 removed.
532        assert!(m.keymap().entries.is_empty() && m.keymap().bindings.is_empty());
533    }
534
535    #[test]
536    fn excerpts_group_by_file_and_sort_by_line() {
537        let t = TempTree::new("group");
538        t.write("a.rs", EIGHT);
539        t.write("b.rs", EIGHT);
540        // Interleaved, out of order, to prove grouping + sorting.
541        let locs = vec![
542            loc(&t.uri("a.rs"), 5),
543            loc(&t.uri("b.rs"), 1),
544            loc(&t.uri("a.rs"), 2),
545        ];
546        let (sources, excerpts, n_files) = build_reference_excerpts(&locs).unwrap();
547        assert_eq!(n_files, 2);
548        assert_eq!(sources.len(), 2);
549        assert_eq!(excerpts.len(), 3);
550        // a.rs's two sites lead, ordered by line: ±2 clamped → [0,4], [3,7].
551        assert_eq!((excerpts[0].start_line, excerpts[0].end_line), (0, 4));
552        assert_eq!((excerpts[1].start_line, excerpts[1].end_line), (3, 7));
553        assert_eq!(excerpts[0].source, excerpts[1].source);
554        assert_ne!(excerpts[0].source, excerpts[2].source);
555    }
556
557    /// `foo(foo)` yields two locations on one line; the user wants one
558    /// excerpt, not a duplicate.
559    #[test]
560    fn several_sites_on_one_line_collapse_to_one_excerpt() {
561        let t = TempTree::new("dedup");
562        t.write("a.rs", EIGHT);
563        let locs = vec![loc(&t.uri("a.rs"), 3), loc(&t.uri("a.rs"), 3)];
564        let (_, excerpts, _) = build_reference_excerpts(&locs).unwrap();
565        assert_eq!(excerpts.len(), 1);
566    }
567
568    #[test]
569    fn no_locations_yields_nothing_to_show() {
570        assert!(build_reference_excerpts(&[]).is_none());
571    }
572
573    #[test]
574    fn all_unreadable_files_yield_nothing_to_show() {
575        let locs = vec![loc("file:///nonexistent/zzz.rs", 0)];
576        assert!(build_reference_excerpts(&locs).is_none());
577    }
578
579    /// One bad path must not cost the user the readable references.
580    #[test]
581    fn an_unreadable_file_is_skipped_not_fatal() {
582        let t = TempTree::new("partial");
583        t.write("good.rs", EIGHT);
584        let locs = vec![
585            loc("file:///nonexistent/gone.rs", 0),
586            loc(&t.uri("good.rs"), 1),
587        ];
588        let (_, excerpts, n_files) = build_reference_excerpts(&locs).unwrap();
589        assert_eq!(n_files, 1);
590        assert_eq!(excerpts.len(), 1);
591    }
592
593    /// A location past EOF (a stale result raced against an edit) must
594    /// clamp rather than produce an out-of-range excerpt.
595    #[test]
596    fn a_line_past_eof_clamps_to_the_last_line() {
597        let t = TempTree::new("clamp");
598        t.write("a.rs", EIGHT);
599        let locs = vec![loc(&t.uri("a.rs"), 9_999)];
600        let (_, excerpts, _) = build_reference_excerpts(&locs).unwrap();
601        assert_eq!(excerpts[0].end_line, 7, "clamped to the file's last line");
602    }
603
604    #[test]
605    fn service_tracks_and_forgets_view_origins() {
606        let svc = LspReferencesService::new();
607        let view = BufferId::next();
608        let origin = ReferencesOrigin {
609            uri: "file:///tmp/a.rs".to_string(),
610            line: 3,
611            character: 7,
612            symbol: "foo".to_string(),
613        };
614        svc.set_origin(view, origin.clone());
615        assert_eq!(svc.origin(view), Some(origin));
616        assert_eq!(svc.tracked_views(), 1);
617        svc.forget(view);
618        assert_eq!(svc.origin(view), None);
619        assert_eq!(svc.tracked_views(), 0);
620    }
621}