Skip to main content

lean_ctx/core/semantic/
coverage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Per-language semantic coverage: how many of a language's file-level call
3//! edges are verified, and whether a language server for it can run here.
4//! Shared by `ctx_graph status`, `lean-ctx doctor` and the dashboard legend.
5
6use std::collections::BTreeMap;
7
8use serde::Serialize;
9
10use crate::core::property_graph::{CodeGraph, EdgeKind};
11
12use super::evidence::{EdgeEvidence, EvidenceGrade};
13
14/// Evidence counts of one language's file-level `calls` edges.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
16pub struct GradeCounts {
17    pub verified: usize,
18    pub resolved: usize,
19    pub heuristic: usize,
20    /// Edges without typed evidence (older graphs, other producers).
21    pub unannotated: usize,
22}
23
24impl GradeCounts {
25    pub fn total(&self) -> usize {
26        self.verified + self.resolved + self.heuristic + self.unannotated
27    }
28
29    /// Verified share in whole percent (0 when there are no edges).
30    pub fn verified_percent(&self) -> usize {
31        (self.verified * 100).checked_div(self.total()).unwrap_or(0)
32    }
33}
34
35/// `calls` edge evidence grouped by the caller file's language (the
36/// `LanguageId` spelling: `rust`, `typescript`, …), sorted by language.
37pub fn coverage_by_language(graph: &CodeGraph) -> BTreeMap<&'static str, GradeCounts> {
38    let mut out: BTreeMap<&'static str, GradeCounts> = BTreeMap::new();
39    for (source, _, metadata) in graph
40        .file_edges_of_kind(&EdgeKind::Calls)
41        .unwrap_or_default()
42    {
43        let Some(lang) = crate::core::language_capabilities::language_for_path(&source) else {
44            continue;
45        };
46        let counts = out.entry(lang.id_str()).or_default();
47        match EdgeEvidence::from_metadata(metadata.as_deref()).map(|e| e.grade) {
48            Some(EvidenceGrade::VerifiedSemantic) => counts.verified += 1,
49            Some(EvidenceGrade::ResolvedStructural) => counts.resolved += 1,
50            Some(EvidenceGrade::HeuristicStructural) => counts.heuristic += 1,
51            None => counts.unannotated += 1,
52        }
53    }
54    out
55}
56
57/// Whether lean-ctx can start a standalone language server for a language.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct ServerStatus {
60    /// Server binary lean-ctx would start (`rust-analyzer`, `gopls`, …).
61    pub binary: String,
62    /// The binary exists *and* can run (a rustup proxy without the installed
63    /// component does not count).
64    pub runnable: bool,
65    /// How to install it, when it cannot run.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub install_hint: Option<&'static str>,
68}
69
70/// Standalone-server status for a language (`LanguageId` spelling) in
71/// `project_root` — the server the router would start there (a project's own
72/// TypeScript counts); `None` when lean-ctx knows no standalone server for it
73/// (a JetBrains IDE may still serve it).
74pub fn server_status(language: &str, project_root: &str) -> Option<ServerStatus> {
75    let default = crate::lsp::config::default_servers()
76        .remove(language)?
77        .command;
78    let resolved = resolved_binary_cached(language, project_root);
79    // JavaScript is served by the TypeScript server.
80    let hint_lang = if language == "javascript" {
81        "typescript"
82    } else {
83        language
84    };
85    Some(ServerStatus {
86        install_hint: resolved
87            .is_none()
88            .then(|| crate::lsp::config::install_hint_for_language(hint_lang)),
89        runnable: resolved.is_some(),
90        binary: resolved.unwrap_or(default),
91    })
92}
93
94/// The server binary for `language` in `project_root`, if one can run.
95/// Resolving may spawn `<proxy> --version`; status surfaces (dashboard
96/// polling) ask repeatedly, so answers are kept for a minute.
97fn resolved_binary_cached(language: &str, project_root: &str) -> Option<String> {
98    use std::collections::HashMap;
99    use std::sync::{LazyLock, Mutex, PoisonError};
100    use std::time::{Duration, Instant};
101    type Key = (String, String);
102    static CACHE: LazyLock<Mutex<HashMap<Key, (Option<String>, Instant)>>> =
103        LazyLock::new(|| Mutex::new(HashMap::new()));
104    const TTL: Duration = Duration::from_mins(1);
105
106    let key = (language.to_string(), project_root.to_string());
107    if let Some((binary, at)) = CACHE
108        .lock()
109        .unwrap_or_else(PoisonError::into_inner)
110        .get(&key)
111        && at.elapsed() < TTL
112    {
113        return binary.clone();
114    }
115    // Exactly what the router would start (a configured `[lsp]` path counts).
116    let binary = crate::lsp::router::standalone_server(language, project_root)
117        .ok()
118        .map(|s| s.binary_name());
119    let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
120    cache.retain(|_, (_, at)| at.elapsed() < TTL);
121    cache.insert(key, (binary.clone(), Instant::now()));
122    binary
123}
124
125/// What a backend negotiated for a language at its last start (the
126/// `initialize` handshake), as recorded in `<data dir>/semantic-backends.json`
127/// — across processes, so `lean-ctx doctor` sees what the MCP server's
128/// backend can do.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
130pub struct NegotiatedBackend {
131    /// Backend identity (`lsp:rust-analyzer@1.97.1`, `jetbrains:…`).
132    pub identity: String,
133    /// Semantic features the backend offered, among those lean-ctx uses.
134    pub features: Vec<String>,
135    /// Features lean-ctx uses that the backend did not offer.
136    pub missing: Vec<String>,
137}
138
139/// The features lean-ctx's semantic layer uses, in display order.
140fn used_features(
141    caps: &crate::lsp::capabilities::SemanticCapabilities,
142) -> [(&'static str, bool); 4] {
143    [
144        ("definition", caps.definition),
145        ("references", caps.references),
146        ("implementations", caps.implementations),
147        ("type hierarchy", caps.type_hierarchy),
148    ]
149}
150
151fn negotiated_path() -> Option<std::path::PathBuf> {
152    crate::core::data_dir::lean_ctx_data_dir()
153        .ok()
154        .map(|d| d.join("semantic-backends.json"))
155}
156
157fn read_negotiated() -> BTreeMap<String, NegotiatedBackend> {
158    negotiated_path()
159        .and_then(|p| std::fs::read_to_string(p).ok())
160        .and_then(|t| serde_json::from_str(&t).ok())
161        .unwrap_or_default()
162}
163
164/// Records what a freshly started backend negotiated for `language`.
165/// Written only when it changed; best effort (a status display, not state
166/// anything depends on).
167pub fn record_negotiated(language: &str, info: &crate::lsp::capabilities::SemanticBackendInfo) {
168    let (offered, absent): (Vec<_>, Vec<_>) = used_features(&info.capabilities)
169        .into_iter()
170        .partition(|(_, on)| *on);
171    let entry = NegotiatedBackend {
172        identity: info.identity(),
173        features: offered.into_iter().map(|(n, _)| n.to_string()).collect(),
174        missing: absent.into_iter().map(|(n, _)| n.to_string()).collect(),
175    };
176    if read_negotiated().get(language) == Some(&entry) {
177        return;
178    }
179    let Some(path) = negotiated_path() else {
180        return;
181    };
182    if let Some(dir) = path.parent() {
183        let _ = std::fs::create_dir_all(dir);
184    }
185    // Backends for several languages (or in several processes) may start at
186    // once: the read-modify-write runs under a cross-process lock so no
187    // start drops another language's entry.
188    let Ok(lock) = std::fs::OpenOptions::new()
189        .create(true)
190        .truncate(false)
191        .write(true)
192        .open(path.with_extension("json.lock"))
193    else {
194        return;
195    };
196    // Each holder does a read, a serialize and an fsync'd atomic write; on a
197    // loaded disk several queued starts outlast 2 s and the late ones silently
198    // dropped their entry. Once per backend start, so waiting longer is cheap.
199    if crate::core::file_lock::acquire_exclusive_timeout(&lock, std::time::Duration::from_secs(10))
200        .is_err()
201    {
202        return;
203    }
204    let mut all = read_negotiated();
205    if all.get(language) != Some(&entry) {
206        all.insert(language.to_string(), entry);
207        if let Ok(json) = serde_json::to_vec_pretty(&all) {
208            let _ = crate::core::atomic_fs::try_atomic_write(&path, &json, None);
209        }
210    }
211    let _ = fs2::FileExt::unlock(&lock);
212}
213
214/// What the last backend started for `language` negotiated, if one ever ran.
215pub fn negotiated(language: &str) -> Option<NegotiatedBackend> {
216    read_negotiated().remove(language)
217}
218
219/// One line per language for terminal output, e.g.
220/// `  rust        96/120 verified (80%) · rust-analyzer ✓ · offers definition, references, implementations; no type hierarchy`,
221/// from [`coverage_by_language`]. Sorted by language; deterministic.
222pub fn coverage_lines(
223    coverage: &BTreeMap<&'static str, GradeCounts>,
224    project_root: &str,
225) -> Vec<String> {
226    let negotiated = read_negotiated();
227    coverage
228        .iter()
229        .map(|(lang, c)| {
230            let server = match server_status(lang, project_root) {
231                Some(s) if s.runnable => format!("{} ✓", s.binary),
232                Some(s) => format!(
233                    "{} not installed ({})",
234                    s.binary,
235                    s.install_hint.unwrap_or("see docs")
236                ),
237                None => "no standalone server (JetBrains IDE only)".to_string(),
238            };
239            let features = negotiated
240                .get(*lang)
241                .map(|n| {
242                    let mut s = format!(" · offers {}", n.features.join(", "));
243                    if !n.missing.is_empty() {
244                        s.push_str(&format!("; no {}", n.missing.join(", no ")));
245                    }
246                    s
247                })
248                .unwrap_or_default();
249            format!(
250                "  {lang:<11} {}/{} verified ({}%) · {server}{features}",
251                c.verified,
252                c.total(),
253                c.verified_percent()
254            )
255        })
256        .collect()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::core::property_graph::Node;
263    use crate::core::semantic::EvidenceOrigin;
264
265    #[test]
266    #[allow(clippy::many_single_char_names)] // graph test nodes
267    fn calls_are_counted_per_caller_language_and_grade() {
268        let g = CodeGraph::open_in_memory().unwrap();
269        let id = |p: &str| g.upsert_node(&Node::file(p)).unwrap();
270        let (a, b, t, u) = (
271            id("src/a.rs"),
272            id("src/b.rs"),
273            id("web/t.ts"),
274            id("web/u.ts"),
275        );
276        let ev = |grade| EdgeEvidence::new(grade, EvidenceOrigin::Enrichment, None, 1);
277        g.upsert_edge_with_evidence(a, b, &EdgeKind::Calls, &ev(EvidenceGrade::VerifiedSemantic))
278            .unwrap();
279        g.upsert_edge_with_evidence(
280            b,
281            a,
282            &EdgeKind::Calls,
283            &ev(EvidenceGrade::HeuristicStructural),
284        )
285        .unwrap();
286        g.upsert_edge_with_evidence(
287            t,
288            u,
289            &EdgeKind::Calls,
290            &ev(EvidenceGrade::ResolvedStructural),
291        )
292        .unwrap();
293
294        let cov = coverage_by_language(&g);
295        let rust = cov["rust"];
296        assert_eq!((rust.verified, rust.heuristic, rust.total()), (1, 1, 2));
297        assert_eq!(rust.verified_percent(), 50);
298        assert_eq!(cov["typescript"].resolved, 1);
299        assert_eq!(
300            GradeCounts::default().verified_percent(),
301            0,
302            "no division by zero"
303        );
304    }
305
306    /// What a backend negotiated survives the process that started it and
307    /// shows up on the coverage line — including what it lacks.
308    #[test]
309    fn negotiated_features_are_recorded_and_shown() {
310        use crate::lsp::capabilities::{
311            SemanticBackendInfo, SemanticBackendKind, SemanticCapabilities,
312        };
313        let _isolated = crate::core::data_dir::isolated_data_dir();
314        let info = SemanticBackendInfo {
315            kind: SemanticBackendKind::Lsp,
316            server_name: Some("rust-analyzer".into()),
317            server_version: Some("1.97.1".into()),
318            capabilities: SemanticCapabilities {
319                definition: true,
320                references: true,
321                implementations: true,
322                ..SemanticCapabilities::default()
323            },
324            utf8_positions: true,
325        };
326        record_negotiated("rust", &info);
327        let n = negotiated("rust").expect("recorded");
328        assert_eq!(n.identity, "lsp:rust-analyzer@1.97.1");
329        assert_eq!(n.missing, ["type hierarchy"]);
330        assert_eq!(negotiated("go"), None);
331
332        let coverage = BTreeMap::from([("rust", GradeCounts::default())]);
333        let line = &coverage_lines(&coverage, "/nonexistent/project")[0];
334        assert!(
335            line.ends_with("· offers definition, references, implementations; no type hierarchy"),
336            "{line}"
337        );
338
339        // Backends starting at once must not drop each other's entries.
340        let langs = ["go", "python", "typescript", "java", "c", "lua"];
341        std::thread::scope(|s| {
342            for lang in langs {
343                let info = info.clone();
344                s.spawn(move || record_negotiated(lang, &info));
345            }
346        });
347        for lang in langs.iter().chain(&["rust"]) {
348            assert!(negotiated(lang).is_some(), "{lang} entry lost");
349        }
350    }
351}