use super::client::{LspClient, LspLocation, LspRequest};
use super::config::LspConfig;
#[cfg(test)]
use super::config::LspServerConfig;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
pub struct LspBridge {
config: LspConfig,
clients: Mutex<HashMap<String, Option<LspClient>>>,
}
impl LspBridge {
pub fn new(config: LspConfig) -> Self {
Self {
config,
clients: Mutex::new(HashMap::new()),
}
}
pub fn from_yaml_path(path: &Path) -> Result<Self, String> {
let raw = std::fs::read_to_string(path).map_err(|e| format!("read lsp config: {}", e))?;
let cfg: LspConfig =
serde_yaml::from_str(&raw).map_err(|e| format!("parse lsp config: {}", e))?;
Ok(Self::new(cfg))
}
pub fn from_leankg_yaml_or_default(path: &Path) -> Self {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return Self::default(),
};
let val: serde_yaml::Value = match serde_yaml::from_str(&raw) {
Ok(v) => v,
Err(_) => return Self::default(),
};
let lsp_block = val.get("lsp").cloned().unwrap_or(serde_yaml::Value::Null);
let cfg: LspConfig = serde_yaml::from_value(lsp_block).unwrap_or_default();
Self::new(cfg)
}
pub fn workspace_for(&self, file_path: &Path) -> PathBuf {
if let Some(root) = &self.config.workspace_root {
return root.clone();
}
find_workspace_root(file_path)
}
pub fn resolve(
&self,
language: &str,
file_path: &Path,
line: u32,
character: u32,
request: LspRequest,
) -> Result<Option<Vec<LspLocation>>, String> {
let workspace_root = self.workspace_for(file_path);
let key = format!("{}::{}", language, workspace_root.display());
let mut cache = self.clients.lock().map_err(|e| e.to_string())?;
let entry = cache.entry(key).or_insert_with(|| {
super::client::try_spawn(language, &self.config, &workspace_root)
.ok()
.flatten()
});
let Some(client) = entry.as_ref() else {
return Ok(None);
};
let result = client.request(request, file_path, line, character);
match result {
Ok(locs) => Ok(Some(locs)),
Err(e) => {
*entry = None;
Err(e)
}
}
}
}
impl Default for LspBridge {
fn default() -> Self {
Self::new(LspConfig::default())
}
}
pub fn find_workspace_root(start: &Path) -> PathBuf {
const MANIFESTS: &[&str] = &[
".git",
"leankg.yaml",
"go.mod",
"Cargo.toml",
"package.json",
"pyproject.toml",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"tsconfig.json",
"Gemfile",
"mix.exs",
"pubspec.yaml",
"Project.toml",
"Package.swift",
];
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
let mut current = if start.exists() {
std::fs::canonicalize(start).unwrap_or_else(|_| start.to_path_buf())
} else {
start.to_path_buf()
};
if current.is_file() {
if let Some(p) = current.parent() {
current = p.to_path_buf();
}
}
loop {
for m in MANIFESTS {
if current.join(m).exists() {
return current;
}
}
if current == home || current.parent().is_none() {
return current;
}
match current.parent() {
Some(p) => current = p.to_path_buf(),
None => return current,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
fn make_bridge(server: Option<(&str, &str)>) -> LspBridge {
let mut servers = HashMap::new();
if let Some((lang, cmd)) = server {
servers.insert(
lang.to_string(),
LspServerConfig {
command: cmd.to_string(),
args: vec![],
extensions: vec![],
initialization_options: None,
},
);
}
LspBridge::new(LspConfig {
servers,
workspace_root: None,
timeout_ms: 1000,
})
}
#[test]
fn bridge_resolve_returns_none_when_no_server() {
let bridge = LspBridge::default();
let r = bridge.resolve(
"go",
std::path::Path::new("/tmp/main.go"),
1,
0,
LspRequest::Definition,
);
assert!(matches!(r, Ok(None)));
}
#[test]
fn bridge_new_with_servers_keeps_config() {
let bridge = make_bridge(Some(("go", "gopls")));
assert!(bridge.config.servers.contains_key("go"));
assert_eq!(bridge.config.timeout_ms, 1000);
}
#[test]
fn workspace_root_finds_manifests_for_all_languages() {
let cases: &[(&str, &str)] = &[
("go", "go.mod"),
("typescript", "package.json"),
("javascript", "package.json"),
("rust", "Cargo.toml"),
("python", "pyproject.toml"),
("java", "pom.xml"),
("kotlin", "build.gradle.kts"),
("ruby", "Gemfile"),
("elixir", "mix.exs"),
("dart", "pubspec.yaml"),
("swift", "Package.swift"),
("csharp", "Project.toml"), ];
for (lang, manifest) in cases {
let tmp = TempDir::new().unwrap();
let root = tmp.path().canonicalize().unwrap();
std::fs::write(root.join(manifest), "").unwrap();
let nested = root.join("service-a").join("src");
std::fs::create_dir_all(&nested).unwrap();
let file = nested.join("main.ext");
std::fs::write(&file, "").unwrap();
let found = find_workspace_root(&file);
assert_eq!(
found,
root,
"[{}] expected root {}, got {}",
lang,
root.display(),
found.display()
);
}
}
#[test]
fn workspace_root_walks_to_nearest_git_repo() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().canonicalize().unwrap();
std::fs::create_dir_all(outer.join(".git")).unwrap();
let svc_a = outer.join("svc-a");
std::fs::create_dir_all(svc_a.join("src")).unwrap();
let file = svc_a.join("src").join("main.go");
std::fs::write(&file, "").unwrap();
let found = find_workspace_root(&file);
assert_eq!(found, outer);
}
#[test]
fn workspace_root_uses_closest_manifest() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path().canonicalize().unwrap();
std::fs::create_dir_all(outer.join(".git")).unwrap();
let svc = outer.join("svc-a");
std::fs::create_dir_all(svc.join(".git")).unwrap();
std::fs::create_dir_all(svc.join("src")).unwrap();
let file = svc.join("src").join("main.go");
std::fs::write(&file, "").unwrap();
let found = find_workspace_root(&file);
assert_eq!(found, svc);
}
#[test]
fn workspace_root_falls_back_to_file_parent_when_no_marker() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().canonicalize().unwrap().join("scratch");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("loose.txt");
std::fs::write(&file, "").unwrap();
let found = find_workspace_root(&file);
assert!(!found.as_os_str().is_empty());
assert!(found.is_absolute());
}
#[test]
fn bridge_caches_per_workspace_root() {
let bridge = make_bridge(Some(("go", "gopls")));
let r1 = bridge.resolve(
"go",
std::path::Path::new("/tmp/svc-a/main.go"),
0,
0,
LspRequest::Definition,
);
let r2 = bridge.resolve(
"go",
std::path::Path::new("/tmp/svc-b/main.go"),
0,
0,
LspRequest::Definition,
);
assert!(r1.is_ok() && r2.is_ok());
let cache = bridge.clients.lock().unwrap();
assert!(cache.len() <= 2);
}
#[test]
fn typed_resolve_flag_gates_bridge_lookup() {
use crate::config::typed_resolve_enabled;
for lang in &["go", "typescript", "python", "rust", "java", "kotlin"] {
assert!(
typed_resolve_enabled("all", lang),
"all should enable {}",
lang
);
assert!(
!typed_resolve_enabled("off", lang),
"off should disable {}",
lang
);
}
assert!(typed_resolve_enabled("go,ts", "go"));
assert!(typed_resolve_enabled("go,ts", "typescript"));
assert!(!typed_resolve_enabled("go,ts", "python"));
}
#[test]
fn e2e_runs_against_leankg_codebase() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let codebase = manifest_dir.clone();
if !codebase.join("Cargo.toml").exists() {
eprintln!("codebase not present; skipping e2e");
return;
}
let target = codebase.join("src/lsp/bridge.rs");
assert!(target.exists(), "target file missing: {}", target.display());
let ws = find_workspace_root(&target);
let canonical_root = std::fs::canonicalize(&codebase).unwrap();
eprintln!(
"e2e: ws={} canonical={}",
ws.display(),
canonical_root.display()
);
assert_eq!(
ws,
canonical_root,
"workspace_for({}) should equal {}",
target.display(),
canonical_root.display()
);
let bridge = LspBridge::default();
let r = bridge.resolve("rust", &target, 100, 0, LspRequest::Definition);
assert!(matches!(r, Ok(None)));
let r2 = bridge.resolve("cobol", &target, 0, 0, LspRequest::Definition);
assert!(matches!(r2, Ok(None)));
let mut checked: std::collections::HashSet<String> = std::collections::HashSet::new();
for entry in walkdir::WalkDir::new(&codebase)
.max_depth(3)
.into_iter()
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let p = entry.path();
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
let lang = match ext {
"go" => Some("go"),
"rs" => Some("rust"),
"ts" | "tsx" => Some("typescript"),
"js" | "jsx" => Some("javascript"),
"py" => Some("python"),
"java" => Some("java"),
"kt" | "kts" => Some("kotlin"),
"rb" => Some("ruby"),
"php" => Some("php"),
"swift" => Some("swift"),
"dart" => Some("dart"),
_ => None,
};
let Some(lang) = lang else { continue };
if !checked.insert(lang.to_string()) {
continue;
}
let ws = find_workspace_root(p);
assert!(
ws.starts_with(&canonical_root) || ws == canonical_root,
"language {} file {} resolved to {} which is outside codebase {}",
lang,
p.display(),
ws.display(),
canonical_root.display()
);
}
assert!(
checked.len() >= 5,
"expected to discover >=5 languages in the codebase, found {}",
checked.len()
);
eprintln!("e2e: verified {} languages", checked.len());
}
#[test]
fn e2e_typed_resolve_flag_for_every_language_in_codebase() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if !manifest_dir.join("Cargo.toml").exists() {
return;
}
use crate::config::typed_resolve_enabled;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for entry in walkdir::WalkDir::new(&manifest_dir)
.max_depth(4)
.into_iter()
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let p = entry.path();
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
let lang = match ext {
"go" => Some("go"),
"rs" => Some("rust"),
"ts" | "tsx" => Some("typescript"),
"js" | "jsx" => Some("javascript"),
"py" => Some("python"),
"java" => Some("java"),
"kt" | "kts" => Some("kotlin"),
"rb" => Some("ruby"),
"php" => Some("php"),
"swift" => Some("swift"),
"dart" => Some("dart"),
"yaml" | "yml" | "toml" | "md" | "json" | "html" | "css" | "sh" | "sql" | "vue"
| "svelte" => Some("config"),
_ => None,
};
let Some(lang) = lang else { continue };
if !seen.insert(lang.to_string()) {
continue;
}
assert!(
typed_resolve_enabled("all", lang),
"typed_resolve=all should enable {}",
lang
);
assert!(
!typed_resolve_enabled("off", lang),
"typed_resolve=off should disable {}",
lang
);
}
assert!(seen.len() >= 4, "saw only {:?}", seen);
eprintln!("e2e typed_resolve: saw {:?}", seen);
}
}