Skip to main content

logicaffeine_lsp/
workspace.rs

1//! Workspace-wide symbol index: every `.lg`/`.md` file under the workspace
2//! folders, analyzed in the background, so symbols resolve across files the
3//! user never opened.
4//!
5//! Open-document state stays authoritative in [`crate::state::ServerState`];
6//! this index answers only what per-document resolution cannot — workspace
7//! symbol queries and cross-file definition lookups.
8
9use std::path::Path;
10
11use dashmap::DashMap;
12use tower_lsp::lsp_types::{Location, Range, SymbolKind, Url};
13
14use crate::index::DefinitionKind;
15use crate::line_index::LineIndex;
16use crate::pipeline;
17
18/// Bounds: a workspace scan must never wedge the server on a monorepo.
19const MAX_FILES: usize = 2_000;
20const MAX_FILE_BYTES: u64 = 512 * 1024;
21const SKIP_DIRS: &[&str] = &["target", "node_modules", "dist", "out", "logs"];
22
23pub struct WorkspaceSymbol {
24    pub name: String,
25    pub kind: SymbolKind,
26    pub container: Option<String>,
27    pub location: Location,
28}
29
30/// One name occurrence in a file, for cross-file references and rename.
31pub struct FileReference {
32    pub name: String,
33    pub range: Range,
34    /// True when the file's own scope resolved this reference — its target
35    /// is local unless this very file defines the workspace-visible symbol.
36    pub resolved_locally: bool,
37}
38
39#[derive(Default)]
40pub struct WorkspaceIndex {
41    files: DashMap<Url, Vec<WorkspaceSymbol>>,
42    refs: DashMap<Url, Vec<FileReference>>,
43}
44
45impl WorkspaceIndex {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    /// Analyze every LOGOS file under `root` (bounded). Blocking — run it on
51    /// a blocking task.
52    pub fn scan_folder(&self, root: &Path) {
53        let mut pending = vec![root.to_path_buf()];
54        let mut seen_files = 0usize;
55        while let Some(dir) = pending.pop() {
56            let Ok(entries) = std::fs::read_dir(&dir) else { continue };
57            for entry in entries.flatten() {
58                let path = entry.path();
59                let name = entry.file_name();
60                let name = name.to_string_lossy();
61                if path.is_dir() {
62                    if !name.starts_with('.') && !SKIP_DIRS.contains(&name.as_ref()) {
63                        pending.push(path);
64                    }
65                    continue;
66                }
67                let is_logos = matches!(
68                    path.extension().and_then(|e| e.to_str()),
69                    Some("lg") | Some("md")
70                );
71                if !is_logos {
72                    continue;
73                }
74                if entry.metadata().map(|m| m.len() > MAX_FILE_BYTES).unwrap_or(true) {
75                    continue;
76                }
77                if seen_files >= MAX_FILES {
78                    log::warn!(
79                        "workspace scan hit the {MAX_FILES}-file bound at {}; remaining files are unindexed",
80                        path.display()
81                    );
82                    return;
83                }
84                seen_files += 1;
85                self.index_file(&path);
86            }
87        }
88    }
89
90    /// (Re-)analyze one file into the index.
91    pub fn index_file(&self, path: &Path) {
92        let Ok(uri) = Url::from_file_path(path) else { return };
93        let Ok(source) = std::fs::read_to_string(path) else {
94            self.files.remove(&uri);
95            return;
96        };
97        let analysis = pipeline::analyze(&source);
98        let line_index = LineIndex::new(&source);
99
100        let symbols = analysis
101            .symbol_index
102            .definitions
103            .iter()
104            .filter(|def| def.span != logicaffeine_language::token::Span::default())
105            .filter_map(|def| {
106                // Locals stay per-document; the workspace surface is API shape.
107                let kind = match def.kind {
108                    DefinitionKind::Function => SymbolKind::FUNCTION,
109                    DefinitionKind::Struct => SymbolKind::STRUCT,
110                    DefinitionKind::Enum => SymbolKind::ENUM,
111                    DefinitionKind::Field => SymbolKind::FIELD,
112                    DefinitionKind::Variant => SymbolKind::ENUM_MEMBER,
113                    DefinitionKind::Theorem => SymbolKind::CLASS,
114                    DefinitionKind::Block => SymbolKind::NAMESPACE,
115                    DefinitionKind::Variable | DefinitionKind::Parameter => return None,
116                };
117                Some(WorkspaceSymbol {
118                    name: def.name.clone(),
119                    kind,
120                    container: def.detail.clone(),
121                    location: Location {
122                        uri: uri.clone(),
123                        range: Range {
124                            start: line_index.position(def.span.start),
125                            end: line_index.position(def.span.end),
126                        },
127                    },
128                })
129            })
130            .collect();
131
132        let references = analysis
133            .symbol_index
134            .references
135            .iter()
136            .map(|reference| FileReference {
137                name: reference.name.clone(),
138                range: Range {
139                    start: line_index.position(reference.span.start),
140                    end: line_index.position(reference.span.end),
141                },
142                resolved_locally: reference.definition_idx.is_some(),
143            })
144            .collect();
145
146        self.files.insert(uri.clone(), symbols);
147        self.refs.insert(uri, references);
148    }
149
150    pub fn remove(&self, uri: &Url) {
151        self.files.remove(uri);
152        self.refs.remove(uri);
153    }
154
155    /// Does this file define `name` as workspace-visible API shape?
156    fn defines(&self, uri: &Url, name: &str) -> bool {
157        self.files
158            .get(uri)
159            .map(|symbols| symbols.iter().any(|s| s.name == name))
160            .unwrap_or(false)
161    }
162
163    /// Every cross-file occurrence of `name` outside `skip` (the URIs whose
164    /// LIVE buffers answer for themselves). A file's occurrence counts when
165    /// its own scope did NOT resolve it (so it reaches across files), or when
166    /// the file itself defines the symbol (its local uses ARE the symbol).
167    pub fn references_of(&self, name: &str, skip: &[&Url]) -> Vec<Location> {
168        let mut locations = Vec::new();
169        for entry in self.refs.iter() {
170            let uri = entry.key();
171            if skip.contains(&uri) {
172                continue;
173            }
174            let file_defines = self.defines(uri, name);
175            for reference in entry.value() {
176                if reference.name == name && (!reference.resolved_locally || file_defines) {
177                    locations.push(Location { uri: uri.clone(), range: reference.range });
178                }
179            }
180        }
181        locations
182    }
183
184    /// Case-insensitive substring query across the workspace.
185    pub fn query(&self, needle: &str, limit: usize) -> Vec<WorkspaceSymbol> {
186        let needle = needle.to_lowercase();
187        let mut hits = Vec::new();
188        for entry in self.files.iter() {
189            for symbol in entry.value() {
190                if needle.is_empty() || symbol.name.to_lowercase().contains(&needle) {
191                    hits.push(WorkspaceSymbol {
192                        name: symbol.name.clone(),
193                        kind: symbol.kind,
194                        container: symbol.container.clone(),
195                        location: symbol.location.clone(),
196                    });
197                    if hits.len() >= limit {
198                        return hits;
199                    }
200                }
201            }
202        }
203        hits
204    }
205
206    /// The definition location for an exact name, for cross-file goto-def.
207    /// Callable (function) and type-like definitions win over incidental
208    /// name matches in other kinds.
209    pub fn definition_of(&self, name: &str) -> Option<Location> {
210        let mut fallback = None;
211        for entry in self.files.iter() {
212            for symbol in entry.value() {
213                if symbol.name != name {
214                    continue;
215                }
216                match symbol.kind {
217                    SymbolKind::FUNCTION | SymbolKind::STRUCT | SymbolKind::ENUM => {
218                        return Some(symbol.location.clone());
219                    }
220                    _ => fallback = Some(symbol.location.clone()),
221                }
222            }
223        }
224        fallback
225    }
226}