1use 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
56const CONTEXT: u32 = 2;
64
65#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ReferencesOrigin {
78 pub uri: String,
80 pub line: u32,
82 pub character: u32,
84 pub symbol: String,
87}
88
89#[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 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 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 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
157pub type LspReferencesServiceHandle = Arc<LspReferencesService>;
162
163pub struct LspReferencesMode;
175
176impl LspReferencesMode {
177 pub fn mode_id() -> ModeId {
178 ModeId::new("lsp-references-mode")
179 }
180}
181
182pub struct LspReferencesModeGuard;
183
184pub const REFRESH_ACTION: &str = "action:lsp-references-refresh";
186
187fn 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 fn keymap(&self) -> Keymap {
212 Keymap::default()
213 }
214
215 fn refresh_action(&self) -> Option<&'static str> {
218 Some(REFRESH_ACTION)
219 }
220
221 fn fold_toggle_action(&self) -> Option<&'static str> {
225 Some(lattice_mode::FOLD_TOGGLE_DEFAULT_ACTION)
226 }
227
228 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
244fn 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#[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 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
340fn 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
363pub 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 lattice_multibuffer::FoldGrouping::SourceFile,
391 );
392
393 if let Some(svc) = activator.services().get::<LspReferencesServiceHandle>() {
394 svc.set_origin(view, origin.clone());
395 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 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
413pub 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
448pub 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
469pub 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 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 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 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 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 #[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 #[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 #[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}