1use std::collections::BTreeMap;
7
8use serde::Serialize;
9
10use crate::core::property_graph::{CodeGraph, EdgeKind};
11
12use super::evidence::{EdgeEvidence, EvidenceGrade};
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
16pub struct GradeCounts {
17 pub verified: usize,
18 pub resolved: usize,
19 pub heuristic: usize,
20 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 pub fn verified_percent(&self) -> usize {
31 (self.verified * 100).checked_div(self.total()).unwrap_or(0)
32 }
33}
34
35pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
59pub struct ServerStatus {
60 pub binary: String,
62 pub runnable: bool,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub install_hint: Option<&'static str>,
68}
69
70pub 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 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
94fn 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
130pub struct NegotiatedBackend {
131 pub identity: String,
133 pub features: Vec<String>,
135 pub missing: Vec<String>,
137}
138
139fn 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
164pub 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 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 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
214pub fn negotiated(language: &str) -> Option<NegotiatedBackend> {
216 read_negotiated().remove(language)
217}
218
219pub 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)] 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 #[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 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}