use std::collections::BTreeMap;
use serde::Serialize;
use crate::core::property_graph::{CodeGraph, EdgeKind};
use super::evidence::{EdgeEvidence, EvidenceGrade};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct GradeCounts {
pub verified: usize,
pub resolved: usize,
pub heuristic: usize,
pub unannotated: usize,
}
impl GradeCounts {
pub fn total(&self) -> usize {
self.verified + self.resolved + self.heuristic + self.unannotated
}
pub fn verified_percent(&self) -> usize {
(self.verified * 100).checked_div(self.total()).unwrap_or(0)
}
}
pub fn coverage_by_language(graph: &CodeGraph) -> BTreeMap<&'static str, GradeCounts> {
let mut out: BTreeMap<&'static str, GradeCounts> = BTreeMap::new();
for (source, _, metadata) in graph
.file_edges_of_kind(&EdgeKind::Calls)
.unwrap_or_default()
{
let Some(lang) = crate::core::language_capabilities::language_for_path(&source) else {
continue;
};
let counts = out.entry(lang.id_str()).or_default();
match EdgeEvidence::from_metadata(metadata.as_deref()).map(|e| e.grade) {
Some(EvidenceGrade::VerifiedSemantic) => counts.verified += 1,
Some(EvidenceGrade::ResolvedStructural) => counts.resolved += 1,
Some(EvidenceGrade::HeuristicStructural) => counts.heuristic += 1,
None => counts.unannotated += 1,
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ServerStatus {
pub binary: String,
pub runnable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub install_hint: Option<&'static str>,
}
pub fn server_status(language: &str, project_root: &str) -> Option<ServerStatus> {
let default = crate::lsp::config::default_servers()
.remove(language)?
.command;
let resolved = resolved_binary_cached(language, project_root);
let hint_lang = if language == "javascript" {
"typescript"
} else {
language
};
Some(ServerStatus {
install_hint: resolved
.is_none()
.then(|| crate::lsp::config::install_hint_for_language(hint_lang)),
runnable: resolved.is_some(),
binary: resolved.unwrap_or(default),
})
}
fn resolved_binary_cached(language: &str, project_root: &str) -> Option<String> {
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, PoisonError};
use std::time::{Duration, Instant};
type Key = (String, String);
static CACHE: LazyLock<Mutex<HashMap<Key, (Option<String>, Instant)>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
const TTL: Duration = Duration::from_mins(1);
let key = (language.to_string(), project_root.to_string());
if let Some((binary, at)) = CACHE
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(&key)
&& at.elapsed() < TTL
{
return binary.clone();
}
let binary = crate::lsp::router::standalone_server(language, project_root)
.ok()
.map(|s| s.binary_name());
let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
cache.retain(|_, (_, at)| at.elapsed() < TTL);
cache.insert(key, (binary.clone(), Instant::now()));
binary
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
pub struct NegotiatedBackend {
pub identity: String,
pub features: Vec<String>,
pub missing: Vec<String>,
}
fn used_features(
caps: &crate::lsp::capabilities::SemanticCapabilities,
) -> [(&'static str, bool); 4] {
[
("definition", caps.definition),
("references", caps.references),
("implementations", caps.implementations),
("type hierarchy", caps.type_hierarchy),
]
}
fn negotiated_path() -> Option<std::path::PathBuf> {
crate::core::data_dir::lean_ctx_data_dir()
.ok()
.map(|d| d.join("semantic-backends.json"))
}
fn read_negotiated() -> BTreeMap<String, NegotiatedBackend> {
negotiated_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn record_negotiated(language: &str, info: &crate::lsp::capabilities::SemanticBackendInfo) {
let (offered, absent): (Vec<_>, Vec<_>) = used_features(&info.capabilities)
.into_iter()
.partition(|(_, on)| *on);
let entry = NegotiatedBackend {
identity: info.identity(),
features: offered.into_iter().map(|(n, _)| n.to_string()).collect(),
missing: absent.into_iter().map(|(n, _)| n.to_string()).collect(),
};
if read_negotiated().get(language) == Some(&entry) {
return;
}
let Some(path) = negotiated_path() else {
return;
};
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let Ok(lock) = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path.with_extension("json.lock"))
else {
return;
};
if crate::core::file_lock::acquire_exclusive_timeout(&lock, std::time::Duration::from_secs(10))
.is_err()
{
return;
}
let mut all = read_negotiated();
if all.get(language) != Some(&entry) {
all.insert(language.to_string(), entry);
if let Ok(json) = serde_json::to_vec_pretty(&all) {
let _ = crate::core::atomic_fs::try_atomic_write(&path, &json, None);
}
}
let _ = fs2::FileExt::unlock(&lock);
}
pub fn negotiated(language: &str) -> Option<NegotiatedBackend> {
read_negotiated().remove(language)
}
pub fn coverage_lines(
coverage: &BTreeMap<&'static str, GradeCounts>,
project_root: &str,
) -> Vec<String> {
let negotiated = read_negotiated();
coverage
.iter()
.map(|(lang, c)| {
let server = match server_status(lang, project_root) {
Some(s) if s.runnable => format!("{} ✓", s.binary),
Some(s) => format!(
"{} not installed ({})",
s.binary,
s.install_hint.unwrap_or("see docs")
),
None => "no standalone server (JetBrains IDE only)".to_string(),
};
let features = negotiated
.get(*lang)
.map(|n| {
let mut s = format!(" · offers {}", n.features.join(", "));
if !n.missing.is_empty() {
s.push_str(&format!("; no {}", n.missing.join(", no ")));
}
s
})
.unwrap_or_default();
format!(
" {lang:<11} {}/{} verified ({}%) · {server}{features}",
c.verified,
c.total(),
c.verified_percent()
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::property_graph::Node;
use crate::core::semantic::EvidenceOrigin;
#[test]
#[allow(clippy::many_single_char_names)] fn calls_are_counted_per_caller_language_and_grade() {
let g = CodeGraph::open_in_memory().unwrap();
let id = |p: &str| g.upsert_node(&Node::file(p)).unwrap();
let (a, b, t, u) = (
id("src/a.rs"),
id("src/b.rs"),
id("web/t.ts"),
id("web/u.ts"),
);
let ev = |grade| EdgeEvidence::new(grade, EvidenceOrigin::Enrichment, None, 1);
g.upsert_edge_with_evidence(a, b, &EdgeKind::Calls, &ev(EvidenceGrade::VerifiedSemantic))
.unwrap();
g.upsert_edge_with_evidence(
b,
a,
&EdgeKind::Calls,
&ev(EvidenceGrade::HeuristicStructural),
)
.unwrap();
g.upsert_edge_with_evidence(
t,
u,
&EdgeKind::Calls,
&ev(EvidenceGrade::ResolvedStructural),
)
.unwrap();
let cov = coverage_by_language(&g);
let rust = cov["rust"];
assert_eq!((rust.verified, rust.heuristic, rust.total()), (1, 1, 2));
assert_eq!(rust.verified_percent(), 50);
assert_eq!(cov["typescript"].resolved, 1);
assert_eq!(
GradeCounts::default().verified_percent(),
0,
"no division by zero"
);
}
#[test]
fn negotiated_features_are_recorded_and_shown() {
use crate::lsp::capabilities::{
SemanticBackendInfo, SemanticBackendKind, SemanticCapabilities,
};
let _isolated = crate::core::data_dir::isolated_data_dir();
let info = SemanticBackendInfo {
kind: SemanticBackendKind::Lsp,
server_name: Some("rust-analyzer".into()),
server_version: Some("1.97.1".into()),
capabilities: SemanticCapabilities {
definition: true,
references: true,
implementations: true,
..SemanticCapabilities::default()
},
utf8_positions: true,
};
record_negotiated("rust", &info);
let n = negotiated("rust").expect("recorded");
assert_eq!(n.identity, "lsp:rust-analyzer@1.97.1");
assert_eq!(n.missing, ["type hierarchy"]);
assert_eq!(negotiated("go"), None);
let coverage = BTreeMap::from([("rust", GradeCounts::default())]);
let line = &coverage_lines(&coverage, "/nonexistent/project")[0];
assert!(
line.ends_with("· offers definition, references, implementations; no type hierarchy"),
"{line}"
);
let langs = ["go", "python", "typescript", "java", "c", "lua"];
std::thread::scope(|s| {
for lang in langs {
let info = info.clone();
s.spawn(move || record_negotiated(lang, &info));
}
});
for lang in langs.iter().chain(&["rust"]) {
assert!(negotiated(lang).is_some(), "{lang} entry lost");
}
}
}