use crate::{ChangedOutput, FileChanges, ImpactOptions, ImpactOutput, LanguageMode};
use log::{debug, info, trace, warn};
use serde_json::json;
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct CapabilityMatrix {
pub call_hierarchy: bool,
pub references: bool,
pub definition: bool,
pub document_symbol: bool,
pub workspace_symbol: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LspConfig {
pub strict: bool,
pub dump_capabilities: bool,
pub mock: bool,
pub mock_caps: Option<super::CapsHint>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LangProfile {
symbol_lang: &'static str,
lsp_language_id: &'static str,
}
const PYTHON_PROFILE: LangProfile = LangProfile {
symbol_lang: "python",
lsp_language_id: "python",
};
fn profile_for_mode(lang: LanguageMode) -> Option<LangProfile> {
match lang {
LanguageMode::Rust => Some(LangProfile {
symbol_lang: "rust",
lsp_language_id: "rust",
}),
LanguageMode::Ruby => Some(LangProfile {
symbol_lang: "ruby",
lsp_language_id: "ruby",
}),
LanguageMode::Python => Some(PYTHON_PROFILE),
LanguageMode::Javascript => Some(LangProfile {
symbol_lang: "javascript",
lsp_language_id: "javascript",
}),
LanguageMode::Typescript => Some(LangProfile {
symbol_lang: "typescript",
lsp_language_id: "typescript",
}),
LanguageMode::Tsx => Some(LangProfile {
symbol_lang: "tsx",
lsp_language_id: "typescriptreact",
}),
LanguageMode::Go => Some(LangProfile {
symbol_lang: "go",
lsp_language_id: "go",
}),
LanguageMode::Java => Some(LangProfile {
symbol_lang: "java",
lsp_language_id: "java",
}),
LanguageMode::Auto => None,
}
}
fn profile_for_path(path: &str) -> Option<LangProfile> {
if path.ends_with(".rs") {
return profile_for_mode(LanguageMode::Rust);
}
if path.ends_with(".rb") {
return profile_for_mode(LanguageMode::Ruby);
}
if path.ends_with(".tsx") {
return profile_for_mode(LanguageMode::Tsx);
}
if path.ends_with(".ts") || path.ends_with(".mts") || path.ends_with(".cts") {
return profile_for_mode(LanguageMode::Typescript);
}
if path.ends_with(".js") || path.ends_with(".mjs") || path.ends_with(".cjs") {
return profile_for_mode(LanguageMode::Javascript);
}
if path.ends_with(".py") {
return Some(PYTHON_PROFILE);
}
if path.ends_with(".go") {
return profile_for_mode(LanguageMode::Go);
}
if path.ends_with(".java") {
return profile_for_mode(LanguageMode::Java);
}
None
}
fn path_matches_mode(path: &str, lang: LanguageMode) -> bool {
match lang {
LanguageMode::Auto => profile_for_path(path).is_some(),
LanguageMode::Rust => path.ends_with(".rs"),
LanguageMode::Ruby => path.ends_with(".rb"),
LanguageMode::Python => path.ends_with(".py"),
LanguageMode::Javascript => {
path.ends_with(".js") || path.ends_with(".mjs") || path.ends_with(".cjs")
}
LanguageMode::Typescript => {
path.ends_with(".ts") || path.ends_with(".mts") || path.ends_with(".cts")
}
LanguageMode::Tsx => path.ends_with(".tsx"),
LanguageMode::Go => path.ends_with(".go"),
LanguageMode::Java => path.ends_with(".java"),
}
}
fn profile_for_path_or_mode(path: &str, lang: LanguageMode) -> Option<LangProfile> {
profile_for_mode(lang).or_else(|| profile_for_path(path))
}
fn did_open_language_id_for_path(path: &str, lang: LanguageMode) -> Option<&'static str> {
profile_for_path_or_mode(path, lang).map(|p| p.lsp_language_id)
}
fn auto_server_mode_for_path(path: &str) -> Option<LanguageMode> {
if path.ends_with(".go") {
Some(LanguageMode::Go)
} else if path.ends_with(".java") {
Some(LanguageMode::Java)
} else {
None
}
}
fn session_mode_for_files(lang: LanguageMode, files: &[String]) -> LanguageMode {
if lang != LanguageMode::Auto {
return lang;
}
let mut chosen: Option<LanguageMode> = None;
for path in files {
let Some(mode) = auto_server_mode_for_path(path) else {
continue;
};
match chosen {
None => chosen = Some(mode),
Some(prev) if prev == mode => {}
Some(_) => return LanguageMode::Auto,
}
}
chosen.unwrap_or(LanguageMode::Auto)
}
fn session_mode_for_symbols(lang: LanguageMode, symbols: &[crate::ir::Symbol]) -> LanguageMode {
if lang != LanguageMode::Auto {
return lang;
}
let mut chosen: Option<LanguageMode> = None;
for sym in symbols {
let mode = match sym.language.as_str() {
"go" => LanguageMode::Go,
"java" => LanguageMode::Java,
_ => continue,
};
match chosen {
None => chosen = Some(mode),
Some(prev) if prev == mode => {}
Some(_) => return LanguageMode::Auto,
}
}
chosen.unwrap_or(LanguageMode::Auto)
}
fn capability_snapshot(c: &CapabilityMatrix) -> String {
format!(
"call_hierarchy={}, references={}, definition={}, document_symbol={}, workspace_symbol={}",
c.call_hierarchy, c.references, c.definition, c.document_symbol, c.workspace_symbol
)
}
fn required_impact_capability_hint(direction: crate::impact::ImpactDirection) -> &'static str {
match direction {
crate::impact::ImpactDirection::Callers => "call_hierarchy or (references/definition)",
crate::impact::ImpactDirection::Callees => "call_hierarchy or (definition/references)",
crate::impact::ImpactDirection::Both => "call_hierarchy or (references/definition)",
}
}
fn required_changed_capability_hint() -> &'static str {
"document_symbol or workspace_symbol"
}
fn strict_changed_capability_error(
lang: LanguageMode,
caps: &CapabilityMatrix,
policy: &str,
) -> anyhow::Error {
anyhow::anyhow!(
"lsp strict changed_symbols capability missing: policy={}, language={:?}, required={}, capabilities=[{}]",
policy,
lang,
required_changed_capability_hint(),
capability_snapshot(caps)
)
}
fn strict_impact_capability_error(
lang: LanguageMode,
direction: crate::impact::ImpactDirection,
caps: &CapabilityMatrix,
from_symbols: bool,
policy: &str,
) -> anyhow::Error {
let phase = if from_symbols {
"impact_from_symbols"
} else {
"impact"
};
anyhow::anyhow!(
"lsp strict {} capability missing: policy={}, language={:?}, direction={:?}, required={}, capabilities=[{}]",
phase,
policy,
lang,
direction,
required_impact_capability_hint(direction),
capability_snapshot(caps)
)
}
fn command_exists(exe: &str, probe_args: &[&str]) -> bool {
std::process::Command::new(exe)
.args(probe_args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
}
fn detect_python_lsp_server() -> Option<(&'static str, Vec<&'static str>)> {
if let Ok(preferred) = std::env::var("DIMPACT_PYTHON_LSP") {
return match preferred.to_ascii_lowercase().as_str() {
"pyright" | "pyright-langserver" => Some(("pyright-langserver", vec!["--stdio"])),
"basedpyright" | "basedpyright-langserver" => {
Some(("basedpyright-langserver", vec!["--stdio"]))
}
"pylsp" => Some(("pylsp", vec![])),
_ => None,
};
}
if command_exists("pyright-langserver", &["--help"]) {
return Some(("pyright-langserver", vec!["--stdio"]));
}
if command_exists("basedpyright-langserver", &["--help"]) {
return Some(("basedpyright-langserver", vec!["--stdio"]));
}
if command_exists("pylsp", &["--help"]) {
return Some(("pylsp", vec![]));
}
None
}
fn server_command_for_mode(lang: LanguageMode) -> Option<(&'static str, Vec<&'static str>)> {
match lang {
LanguageMode::Rust => Some(("rust-analyzer", vec![])),
LanguageMode::Ruby => Some(("ruby-lsp", vec![])),
LanguageMode::Python => detect_python_lsp_server(),
LanguageMode::Javascript | LanguageMode::Typescript | LanguageMode::Tsx => {
Some(("typescript-language-server", vec!["--stdio"]))
}
LanguageMode::Go => Some(("gopls", vec![])),
LanguageMode::Java => Some(("jdtls", vec![])),
LanguageMode::Auto => detect_python_lsp_server(),
}
}
pub struct LspSession {
_cfg: LspConfig,
pub capabilities: CapabilityMatrix,
child: Option<std::process::Child>,
stdin: Option<std::process::ChildStdin>,
stdout: Option<std::process::ChildStdout>,
next_id: std::sync::atomic::AtomicU64,
doc_symbol_cache: std::collections::HashMap<String, Vec<serde_json::Value>>,
prepare_call_hierarchy_cache: std::collections::HashMap<String, Vec<serde_json::Value>>,
definition_cache: std::collections::HashMap<String, Vec<serde_json::Value>>,
references_cache: std::collections::HashMap<String, Vec<serde_json::Value>>,
incoming_calls_cache: std::collections::HashMap<String, Vec<serde_json::Value>>,
outgoing_calls_cache: std::collections::HashMap<String, Vec<serde_json::Value>>,
opened_docs: std::collections::HashSet<String>,
}
impl LspSession {
pub fn new(lang: LanguageMode, cfg: LspConfig) -> anyhow::Result<Self> {
info!(
"lsp: initializing session (strict={}, mock={})",
cfg.strict, cfg.mock
);
if std::env::var("DIMPACT_DISABLE_REAL_LSP").ok().as_deref() == Some("1") && !cfg.mock {
anyhow::bail!("real LSP disabled by DIMPACT_DISABLE_REAL_LSP=1")
}
if cfg.mock {
let caps = if let Some(h) = cfg.mock_caps {
CapabilityMatrix {
call_hierarchy: h.call_hierarchy,
references: h.references,
definition: h.definition,
document_symbol: h.document_symbol,
workspace_symbol: h.workspace_symbol,
}
} else {
CapabilityMatrix {
call_hierarchy: true,
references: true,
definition: true,
document_symbol: true,
workspace_symbol: true,
}
};
return Ok(Self {
_cfg: cfg,
capabilities: caps,
child: None,
stdin: None,
stdout: None,
next_id: std::sync::atomic::AtomicU64::new(1),
doc_symbol_cache: std::collections::HashMap::new(),
prepare_call_hierarchy_cache: std::collections::HashMap::new(),
definition_cache: std::collections::HashMap::new(),
references_cache: std::collections::HashMap::new(),
incoming_calls_cache: std::collections::HashMap::new(),
outgoing_calls_cache: std::collections::HashMap::new(),
opened_docs: std::collections::HashSet::new(),
});
}
let cmd = server_command_for_mode(lang);
let Some((exe, args)) = cmd else {
anyhow::bail!("lsp server not determined for language")
};
let mut child = std::process::Command::new(exe)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("no stdin"))?;
let mut stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("no stdout"))?;
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let root_uri = path_to_uri(&cwd);
let init = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": std::process::id(),
"rootUri": root_uri,
"workspaceFolders": [ { "uri": root_uri, "name": "workspace" } ],
"capabilities": {},
"trace": "off",
}
});
let buf = encode_jsonrpc_message(&init);
use std::io::Write;
stdin.write_all(&buf)?;
use std::io::Read;
let mut acc: Vec<u8> = Vec::new();
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_millis(2000);
loop {
let mut tmp = [0u8; 4096];
match stdout.read(&mut tmp) {
Ok(0) => break,
Ok(n) => {
acc.extend_from_slice(&tmp[..n]);
if let Ok((val, _used)) = decode_jsonrpc_message(&acc) {
let caps = val
.get("result")
.and_then(|r| r.get("capabilities"))
.cloned()
.unwrap_or(json!({}));
let m = CapabilityMatrix {
call_hierarchy: caps.get("callHierarchyProvider").is_some(),
references: caps.get("referencesProvider").is_some(),
definition: caps.get("definitionProvider").is_some(),
document_symbol: caps.get("documentSymbolProvider").is_some(),
workspace_symbol: caps.get("workspaceSymbolProvider").is_some(),
};
info!(
"lsp: capabilities: {}",
serde_json::to_string(&m).unwrap_or_default()
);
let initialized =
json!({"jsonrpc":"2.0","method":"initialized","params":{}});
let _ = stdin.write_all(&encode_jsonrpc_message(&initialized));
return Ok(Self {
_cfg: cfg,
capabilities: m,
child: Some(child),
stdin: Some(stdin),
stdout: Some(stdout),
next_id: std::sync::atomic::AtomicU64::new(2),
doc_symbol_cache: std::collections::HashMap::new(),
prepare_call_hierarchy_cache: std::collections::HashMap::new(),
definition_cache: std::collections::HashMap::new(),
references_cache: std::collections::HashMap::new(),
incoming_calls_cache: std::collections::HashMap::new(),
outgoing_calls_cache: std::collections::HashMap::new(),
opened_docs: std::collections::HashSet::new(),
});
}
}
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
} else {
break;
}
}
}
if start.elapsed() > timeout {
break;
}
}
let _ = child.kill();
anyhow::bail!("lsp initialize timeout or invalid response")
}
fn next_request_id(&self) -> u64 {
self.next_id
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
}
fn pos_cache_key(uri: &str, line0: u32, character0: u32) -> String {
format!("{}:{}:{}", uri, line0, character0)
}
fn call_item_key(item: &serde_json::Value) -> Option<String> {
let obj = if item.get("name").is_some() {
item
} else if let Some(from) = item.get("from") {
from
} else if let Some(to) = item.get("to") {
to
} else {
return None;
};
let uri = obj.get("uri").and_then(|v| v.as_str())?;
let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
let kind = obj.get("kind").and_then(|v| v.as_u64()).unwrap_or(0);
let range = obj.get("selectionRange").or_else(|| obj.get("range"));
let line0 = range
.and_then(|r| r.get("start"))
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0);
let ch0 = range
.and_then(|r| r.get("start"))
.and_then(|s| s.get("character"))
.and_then(|n| n.as_u64())
.unwrap_or(0);
Some(format!("{}:{}:{}:{}:{}", uri, name, kind, line0, ch0))
}
#[allow(dead_code)]
pub fn request(
&mut self,
method: &str,
params: serde_json::Value,
timeout_ms: u64,
) -> anyhow::Result<serde_json::Value> {
if self._cfg.mock || self.stdin.is_none() || self.stdout.is_none() {
anyhow::bail!("lsp request not available (mock or no io)")
}
let id = self.next_request_id();
debug!("lsp: request id={} method={}", id, method);
let req = json!({"jsonrpc":"2.0","id": id, "method": method, "params": params});
let buf = encode_jsonrpc_message(&req);
use std::io::Write;
self.stdin.as_mut().unwrap().write_all(&buf)?;
use std::io::Read;
let mut acc: Vec<u8> = Vec::new();
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_millis(timeout_ms);
loop {
let mut tmp = [0u8; 8192];
let n = self.stdout.as_mut().unwrap().read(&mut tmp)?;
if n == 0 {
anyhow::bail!("lsp server closed")
}
acc.extend_from_slice(&tmp[..n]);
while let Ok((val, used)) = decode_jsonrpc_message(&acc) {
acc.drain(..used);
if val.get("id").and_then(|v| v.as_u64()) == Some(id) {
if val.get("error").is_some() {
warn!("lsp: error for method {} id={}", method, id);
anyhow::bail!("lsp error response")
}
trace!("lsp: response id={} method={}", id, method);
return Ok(val.get("result").cloned().unwrap_or(json!({})));
}
}
if start.elapsed() > timeout {
anyhow::bail!("lsp request timeout")
}
}
}
#[allow(dead_code)]
pub fn notify(&mut self, method: &str, params: serde_json::Value) -> anyhow::Result<()> {
if self._cfg.mock || self.stdin.is_none() {
return Ok(());
}
debug!("lsp: notify method={}", method);
let notif = json!({"jsonrpc":"2.0","method": method, "params": params});
let buf = encode_jsonrpc_message(¬if);
use std::io::Write;
self.stdin.as_mut().unwrap().write_all(&buf)?;
Ok(())
}
fn ensure_did_open(&mut self, uri: &str, language_id: &str, text: &str) -> anyhow::Result<()> {
if self.opened_docs.contains(uri) {
return Ok(());
}
self.notify(
"textDocument/didOpen",
json!({
"textDocument": {
"uri": uri,
"languageId": language_id,
"version": 1,
"text": text,
}
}),
)?;
self.opened_docs.insert(uri.to_string());
Ok(())
}
#[allow(dead_code)]
pub fn shutdown(mut self) {
if self._cfg.mock {
return;
}
if let (Some(mut _stdin), Some(mut child)) = (self.stdin.take(), self.child.take()) {
use std::io::Write;
let _ = _stdin.write_all(&encode_jsonrpc_message(
&json!({"jsonrpc":"2.0","id":9999,"method":"shutdown"}),
));
let _ = _stdin.write_all(&encode_jsonrpc_message(
&json!({"jsonrpc":"2.0","method":"exit"}),
));
let _ = child.kill();
}
}
pub fn probe_update(&mut self) {
if !self._cfg.mock {
let process_exited = self
.child
.as_mut()
.and_then(|c| c.try_wait().ok().flatten())
.is_some();
if process_exited || self.stdin.is_none() || self.stdout.is_none() {
self.capabilities = CapabilityMatrix::default();
return;
}
if self.capabilities.workspace_symbol
&& self
.request(
"workspace/symbol",
json!({ "query": "__dimpact_probe__" }),
400,
)
.is_err()
{
self.capabilities.workspace_symbol = false;
}
}
if let Some(h) = self._cfg.mock_caps {
self.capabilities.call_hierarchy &= h.call_hierarchy;
self.capabilities.references &= h.references;
self.capabilities.definition &= h.definition;
self.capabilities.document_symbol &= h.document_symbol;
self.capabilities.workspace_symbol &= h.workspace_symbol;
}
}
pub fn probe_files(&mut self, files: &[String]) {
if self._cfg.mock {
return;
}
if self.stdin.is_none() || self.stdout.is_none() {
return;
}
let first_supported = files.iter().find(|p| profile_for_path(p).is_some());
if let Some(path) = first_supported {
let p = std::path::Path::new(path);
if let Ok(abs) = std::fs::canonicalize(p) {
let uri = path_to_uri(&abs);
let _ = self
.request(
"textDocument/documentSymbol",
json!({"textDocument": {"uri": uri}}),
400,
)
.map(|_| {
self.capabilities.document_symbol = true;
});
let _ = self.request("textDocument/prepareCallHierarchy", json!({"textDocument": {"uri": uri}, "position": {"line": 0, "character": 0}}), 400)
.map(|_| { self.capabilities.call_hierarchy = true; });
let pos = json!({"line": 0, "character": 0});
let _ = self.request("textDocument/references", json!({"textDocument": {"uri": uri}, "position": pos, "context": {"includeDeclaration": true}}), 300)
.map(|_| { self.capabilities.references = true; });
let _ = self
.request(
"textDocument/definition",
json!({"textDocument": {"uri": uri}, "position": pos}),
300,
)
.map(|_| {
self.capabilities.definition = true;
});
}
}
}
fn req_prepare_call_hierarchy(
&mut self,
uri: &str,
line0: u32,
character0: u32,
) -> anyhow::Result<Vec<serde_json::Value>> {
let key = Self::pos_cache_key(uri, line0, character0);
if let Some(cached) = self.prepare_call_hierarchy_cache.get(&key) {
trace!(
"lsp: prepareCallHierarchy uri={} line={} ch={} -> {} (cache)",
uri,
line0,
character0,
cached.len()
);
return Ok(cached.clone());
}
let params = json!({"textDocument": {"uri": uri}, "position": {"line": line0, "character": character0}});
let v = self.request("textDocument/prepareCallHierarchy", params.clone(), 700)?;
let mut out = v.as_array().cloned().unwrap_or_default();
if out.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(60));
let v2 = self.request("textDocument/prepareCallHierarchy", params, 900)?;
out = v2.as_array().cloned().unwrap_or_default();
}
self.prepare_call_hierarchy_cache.insert(key, out.clone());
trace!(
"lsp: prepareCallHierarchy uri={} line={} ch={} -> {}",
uri,
line0,
character0,
out.len()
);
Ok(out)
}
fn req_incoming_calls(
&mut self,
item: &serde_json::Value,
) -> anyhow::Result<Vec<serde_json::Value>> {
let cache_key = Self::call_item_key(item);
if let Some(key) = cache_key.as_ref()
&& let Some(cached) = self.incoming_calls_cache.get(key)
{
trace!("lsp: incomingCalls -> {} (cache)", cached.len());
return Ok(cached.clone());
}
let params = json!({"item": item});
let v = self.request("callHierarchy/incomingCalls", params, 1200)?;
let out = v.as_array().cloned().unwrap_or_default();
if !out.is_empty()
&& let Some(key) = cache_key
{
self.incoming_calls_cache.insert(key, out.clone());
}
trace!("lsp: incomingCalls -> {}", out.len());
Ok(out)
}
fn req_outgoing_calls(
&mut self,
item: &serde_json::Value,
) -> anyhow::Result<Vec<serde_json::Value>> {
let cache_key = Self::call_item_key(item);
if let Some(key) = cache_key.as_ref()
&& let Some(cached) = self.outgoing_calls_cache.get(key)
{
trace!("lsp: outgoingCalls -> {} (cache)", cached.len());
return Ok(cached.clone());
}
let params = json!({"item": item});
let v = self.request("callHierarchy/outgoingCalls", params, 1200)?;
let out = v.as_array().cloned().unwrap_or_default();
if !out.is_empty()
&& let Some(key) = cache_key
{
self.outgoing_calls_cache.insert(key, out.clone());
}
trace!("lsp: outgoingCalls -> {}", out.len());
Ok(out)
}
fn req_document_symbol(&mut self, uri: &str) -> anyhow::Result<Vec<serde_json::Value>> {
let params = json!({"textDocument": {"uri": uri}});
let v = self.request("textDocument/documentSymbol", params, 800)?;
let out = v.as_array().cloned().unwrap_or_default();
if !out.is_empty() {
self.doc_symbol_cache.insert(uri.to_string(), out.clone());
trace!("lsp: documentSymbol uri={} -> {}", uri, out.len());
return Ok(out);
}
if let Some(cached) = self.doc_symbol_cache.get(uri) {
trace!(
"lsp: documentSymbol uri={} -> 0 (use cache={})",
uri,
cached.len()
);
return Ok(cached.clone());
}
trace!("lsp: documentSymbol uri={} -> 0", uri);
Ok(out)
}
fn req_definition(
&mut self,
uri: &str,
line0: u32,
character0: u32,
) -> anyhow::Result<Vec<serde_json::Value>> {
let key = Self::pos_cache_key(uri, line0, character0);
if let Some(cached) = self.definition_cache.get(&key) {
return Ok(cached.clone());
}
let params = json!({"textDocument": {"uri": uri}, "position": {"line": line0, "character": character0}});
let v = self.request("textDocument/definition", params, 800)?;
let mut out = Vec::new();
if let Some(arr) = v.as_array() {
out.extend(arr.clone());
} else if v.get("targetUri").is_some() {
out.push(v.clone());
}
self.definition_cache.insert(key, out.clone());
Ok(out)
}
fn req_references(
&mut self,
uri: &str,
line0: u32,
character0: u32,
) -> anyhow::Result<Vec<serde_json::Value>> {
let key = Self::pos_cache_key(uri, line0, character0);
if let Some(cached) = self.references_cache.get(&key) {
trace!(
"lsp: references uri={} line={} ch={} -> {} (cache)",
uri,
line0,
character0,
cached.len()
);
return Ok(cached.clone());
}
let params = json!({"textDocument": {"uri": uri}, "position": {"line": line0, "character": character0}, "context": {"includeDeclaration": true}});
let v = self.request("textDocument/references", params.clone(), 1200)?;
let mut out = v.as_array().cloned().unwrap_or_default();
if out.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(60));
let v2 = self.request("textDocument/references", params, 1400)?;
out = v2.as_array().cloned().unwrap_or_default();
}
self.references_cache.insert(key, out.clone());
trace!(
"lsp: references uri={} line={} ch={} -> {}",
uri,
line0,
character0,
out.len()
);
Ok(out)
}
}
pub(crate) fn encode_jsonrpc_message(value: &serde_json::Value) -> Vec<u8> {
let body = value.to_string();
let header = format!("Content-Length: {}\r\n\r\n", body.len());
let mut out = header.into_bytes();
out.extend_from_slice(body.as_bytes());
out
}
pub(crate) fn decode_jsonrpc_message(input: &[u8]) -> anyhow::Result<(serde_json::Value, usize)> {
let mut idx = None;
for i in 0..input.len().saturating_sub(3) {
if &input[i..i + 4] == b"\r\n\r\n" {
idx = Some(i);
break;
}
}
let Some(hdr_end) = idx else {
anyhow::bail!("incomplete header")
};
let header = std::str::from_utf8(&input[..hdr_end]).map_err(|e| anyhow::anyhow!(e))?;
let mut content_len: Option<usize> = None;
for line in header.split("\r\n") {
if let Some(rest) = line.strip_prefix("Content-Length:") {
let n = rest
.trim()
.parse::<usize>()
.map_err(|e| anyhow::anyhow!(e))?;
content_len = Some(n);
}
}
let len = content_len.ok_or_else(|| anyhow::anyhow!("missing Content-Length"))?;
let body_start = hdr_end + 4;
if input.len() < body_start + len {
anyhow::bail!("incomplete body")
}
let body = &input[body_start..body_start + len];
let value: serde_json::Value = serde_json::from_slice(body)?;
Ok((value, body_start + len))
}
#[derive(Default)]
pub struct LspEngine {
cfg: super::EngineConfig,
fallback: super::ts::TsEngine,
auto_policy: Option<super::AutoPolicy>,
}
impl LspEngine {
pub fn new(cfg: super::EngineConfig) -> Self {
Self::new_with_auto_policy(cfg, None)
}
pub fn new_with_auto_policy(
cfg: super::EngineConfig,
auto_policy: Option<super::AutoPolicy>,
) -> Self {
Self {
cfg,
fallback: super::ts::TsEngine,
auto_policy,
}
}
fn policy_label(&self) -> &'static str {
match self.auto_policy {
Some(super::AutoPolicy::StrictIfAvailable) => "strict-if-available",
Some(super::AutoPolicy::Compat) => "compat",
None if self.cfg.lsp_strict => "strict",
None => "non-strict",
}
}
fn prefers_lsp_in_auto(&self) -> bool {
matches!(self.auto_policy, Some(super::AutoPolicy::StrictIfAvailable))
}
fn log_capability_fallback_changed(
&self,
lang: LanguageMode,
caps: &CapabilityMatrix,
reason: &str,
) {
warn!(
"engine.lsp.changed_symbols: policy={} fallback=ts reason={} language={:?} required={} capabilities=[{}]",
self.policy_label(),
reason,
lang,
required_changed_capability_hint(),
capability_snapshot(caps)
);
}
fn log_capability_fallback_impact(
&self,
lang: LanguageMode,
direction: crate::impact::ImpactDirection,
caps: &CapabilityMatrix,
reason: &str,
) {
warn!(
"engine.lsp.impact: policy={} fallback=ts reason={} language={:?} direction={:?} required={} capabilities=[{}]",
self.policy_label(),
reason,
lang,
direction,
required_impact_capability_hint(direction),
capability_snapshot(caps)
);
}
}
impl super::AnalysisEngine for LspEngine {
fn changed_symbols(
&self,
diffs: &[FileChanges],
lang: LanguageMode,
) -> anyhow::Result<ChangedOutput> {
info!(
"engine.lsp.changed_symbols: strict={}, files={}",
self.cfg.lsp_strict,
diffs.len()
);
let files_list: Vec<String> = diffs.iter().filter_map(|fc| fc.new_path.clone()).collect();
let session_lang = session_mode_for_files(lang, &files_list);
if !self.cfg.lsp_strict && !self.prefers_lsp_in_auto() {
if self.cfg.dump_capabilities {
let lsp_cfg = LspConfig {
strict: false,
dump_capabilities: true,
mock: self.cfg.mock_lsp,
mock_caps: self.cfg.mock_caps,
};
match LspSession::new(session_lang, lsp_cfg) {
Ok(mut s) => {
s.probe_update();
eprintln!(
"{}",
serde_json::to_string(&s.capabilities)
.unwrap_or_else(|_| "{}".to_string())
);
}
Err(_) => {
eprintln!(
"{}",
serde_json::to_string(&CapabilityMatrix::default())
.unwrap_or_else(|_| "{}".to_string())
);
}
}
}
return self.fallback.changed_symbols(diffs, lang);
}
let lsp_cfg = LspConfig {
strict: self.cfg.lsp_strict,
dump_capabilities: self.cfg.dump_capabilities,
mock: self.cfg.mock_lsp,
mock_caps: self.cfg.mock_caps,
};
match LspSession::new(session_lang, lsp_cfg) {
Ok(mut _sess) => {
_sess.probe_update();
_sess.probe_files(&files_list);
if self.cfg.dump_capabilities {
eprintln!(
"{}",
serde_json::to_string(&_sess.capabilities)
.unwrap_or_else(|_| "{}".to_string())
);
}
match decide_changed_strategy(&_sess.capabilities) {
ChangedStrategy::DocumentSymbol | ChangedStrategy::WorkspaceSymbol => {
let out = lsp_changed_symbols(&mut _sess, diffs, lang)?;
if out.changed_symbols.is_empty() {
if self.cfg.lsp_strict {
anyhow::bail!(
"lsp changed_symbols returned no symbols; policy=strict language={:?}",
lang
)
} else {
self.log_capability_fallback_changed(
lang,
&_sess.capabilities,
"empty-changed-symbols",
);
self.fallback.changed_symbols(diffs, lang)
}
} else {
Ok(out)
}
}
ChangedStrategy::TsFallback => {
if self.cfg.lsp_strict {
Err(strict_changed_capability_error(
lang,
&_sess.capabilities,
self.policy_label(),
))
} else {
self.log_capability_fallback_changed(
lang,
&_sess.capabilities,
"missing-document-or-workspace-symbol",
);
self.fallback.changed_symbols(diffs, lang)
}
}
}
}
Err(e) => {
if self.cfg.dump_capabilities {
eprintln!(
"{}",
serde_json::to_string(&CapabilityMatrix::default())
.unwrap_or_else(|_| "{}".to_string())
);
}
if self.cfg.lsp_strict {
Err(e)
} else {
warn!(
"engine.lsp.changed_symbols: policy={} fallback=ts reason=session-init-failed language={:?} error={}",
self.policy_label(),
lang,
e
);
self.fallback.changed_symbols(diffs, lang)
}
}
}
}
fn impact(
&self,
diffs: &[FileChanges],
lang: LanguageMode,
opts: &ImpactOptions,
) -> anyhow::Result<ImpactOutput> {
info!(
"engine.lsp.impact: strict={}, direction={:?}, max_depth={:?}",
self.cfg.lsp_strict, opts.direction, opts.max_depth
);
let files_list: Vec<String> = diffs.iter().filter_map(|fc| fc.new_path.clone()).collect();
let session_lang = session_mode_for_files(lang, &files_list);
if !self.cfg.lsp_strict && self.cfg.dump_capabilities {
let lsp_cfg = LspConfig {
strict: false,
dump_capabilities: true,
mock: self.cfg.mock_lsp,
mock_caps: self.cfg.mock_caps,
};
match LspSession::new(session_lang, lsp_cfg) {
Ok(mut s) => {
s.probe_update();
eprintln!(
"{}",
serde_json::to_string(&s.capabilities).unwrap_or_else(|_| "{}".to_string())
);
}
Err(_) => {
eprintln!(
"{}",
serde_json::to_string(&CapabilityMatrix::default())
.unwrap_or_else(|_| "{}".to_string())
);
}
}
}
if !self.cfg.lsp_strict && !self.cfg.mock_lsp && !self.prefers_lsp_in_auto() {
return self.fallback.impact(diffs, lang, opts);
}
let lsp_cfg = LspConfig {
strict: self.cfg.lsp_strict,
dump_capabilities: self.cfg.dump_capabilities,
mock: self.cfg.mock_lsp,
mock_caps: self.cfg.mock_caps,
};
match LspSession::new(session_lang, lsp_cfg) {
Ok(mut _sess) => {
_sess.probe_update();
_sess.probe_files(&files_list);
if self.cfg.dump_capabilities {
eprintln!(
"{}",
serde_json::to_string(&_sess.capabilities)
.unwrap_or_else(|_| "{}".to_string())
);
}
if _sess.capabilities.call_hierarchy {
let changed = lsp_changed_symbols(&mut _sess, diffs, lang)?;
if _sess._cfg.mock {
let (index, refs) = crate::impact::build_project_graph()?;
return Ok(crate::impact::compute_impact(
&changed.changed_symbols,
&index,
&refs,
opts,
));
}
let out = lsp_impact_bfs(&mut _sess, changed.changed_symbols.clone(), opts);
match out {
Ok(o)
if !o.impacted_symbols.is_empty()
|| changed.changed_symbols.is_empty() =>
{
Ok(o)
}
Ok(mut o_empty) => {
if matches!(
opts.direction,
crate::impact::ImpactDirection::Callers
| crate::impact::ImpactDirection::Both
) && (_sess.capabilities.references || _sess.capabilities.definition)
{
let out2 = lsp_impact_references(
&mut _sess,
changed.changed_symbols.clone(),
opts,
)?;
if !out2.impacted_symbols.is_empty() {
return Ok(out2);
}
}
if matches!(
opts.direction,
crate::impact::ImpactDirection::Callees
| crate::impact::ImpactDirection::Both
) {
let (callees, extra_edges) =
scan_callees_for_changed(&mut _sess, &changed.changed_symbols);
if !callees.is_empty() {
o_empty.impacted_symbols = callees;
if opts.with_edges.unwrap_or(false) {
o_empty.edges = extra_edges;
}
let mut files: Vec<String> = o_empty
.impacted_symbols
.iter()
.map(|s| s.file.clone())
.collect();
files.sort();
files.dedup();
o_empty.impacted_files = files;
return Ok(o_empty);
}
}
if o_empty.impacted_symbols.is_empty()
&& !changed.changed_symbols.is_empty()
&& let Ok((index, refs)) = lsp_build_project_graph(&mut _sess)
{
let out2 = crate::impact::compute_impact(
&changed.changed_symbols,
&index,
&refs,
opts,
);
return Ok(out2);
}
if self.cfg.lsp_strict {
Ok(o_empty)
} else {
self.log_capability_fallback_impact(
lang,
opts.direction,
&_sess.capabilities,
"lsp-produced-empty-impact",
);
self.fallback.impact(diffs, lang, opts)
}
}
Err(e) => {
if matches!(
opts.direction,
crate::impact::ImpactDirection::Callers
| crate::impact::ImpactDirection::Both
) && (_sess.capabilities.references || _sess.capabilities.definition)
{
let out2 = lsp_impact_references(
&mut _sess,
changed.changed_symbols.clone(),
opts,
)?;
if !out2.impacted_symbols.is_empty() || self.cfg.lsp_strict {
return Ok(out2);
}
}
if matches!(
opts.direction,
crate::impact::ImpactDirection::Callees
| crate::impact::ImpactDirection::Both
) {
let (callees, extra_edges) =
scan_callees_for_changed(&mut _sess, &changed.changed_symbols);
if !callees.is_empty() {
let mut summary_depth_by_symbol_id =
std::collections::HashMap::new();
for sym in &callees {
record_lsp_min_depth(
&mut summary_depth_by_symbol_id,
&sym.id.0,
1,
);
}
return Ok(build_impact_output(
changed.changed_symbols.clone(),
callees,
extra_edges,
summary_depth_by_symbol_id,
opts.with_edges.unwrap_or(false),
));
}
}
if let Ok((index, refs)) = lsp_build_project_graph(&mut _sess) {
let out2 = crate::impact::compute_impact(
&changed.changed_symbols,
&index,
&refs,
opts,
);
if !out2.impacted_symbols.is_empty() || self.cfg.lsp_strict {
return Ok(out2);
}
}
if self.cfg.lsp_strict {
Err(e)
} else {
warn!(
"engine.lsp.impact: policy={} fallback=ts reason=lsp-impact-failed language={:?} direction={:?} error={}",
self.policy_label(),
lang,
opts.direction,
e
);
self.fallback.impact(diffs, lang, opts)
}
}
}
} else if _sess.capabilities.references || _sess.capabilities.definition {
let changed = lsp_changed_symbols(&mut _sess, diffs, lang)?;
lsp_impact_references_definition(
&mut _sess,
changed.changed_symbols.clone(),
opts,
)
} else if self.cfg.lsp_strict {
Err(strict_impact_capability_error(
lang,
opts.direction,
&_sess.capabilities,
false,
self.policy_label(),
))
} else {
self.log_capability_fallback_impact(
lang,
opts.direction,
&_sess.capabilities,
"missing-impact-capabilities",
);
self.fallback.impact(diffs, lang, opts)
}
}
Err(e) => {
if self.cfg.dump_capabilities {
eprintln!(
"{}",
serde_json::to_string(&CapabilityMatrix::default())
.unwrap_or_else(|_| "{}".to_string())
);
}
if self.cfg.lsp_strict {
Err(e)
} else {
warn!(
"engine.lsp.impact: policy={} fallback=ts reason=session-init-failed language={:?} direction={:?} error={}",
self.policy_label(),
lang,
opts.direction,
e
);
self.fallback.impact(diffs, lang, opts)
}
}
}
}
fn impact_from_symbols(
&self,
changed: &[crate::ir::Symbol],
lang: LanguageMode,
opts: &ImpactOptions,
) -> anyhow::Result<ImpactOutput> {
info!(
"engine.lsp.impact_from_symbols: strict={}, seeds={} dir={:?}",
self.cfg.lsp_strict,
changed.len(),
opts.direction
);
let lsp_cfg = LspConfig {
strict: self.cfg.lsp_strict,
dump_capabilities: self.cfg.dump_capabilities,
mock: self.cfg.mock_lsp,
mock_caps: self.cfg.mock_caps,
};
let session_lang = session_mode_for_symbols(lang, changed);
let mut sess = match LspSession::new(session_lang, lsp_cfg) {
Ok(s) => s,
Err(e) => {
if self.cfg.lsp_strict {
return Err(e);
}
warn!(
"engine.lsp.impact_from_symbols: policy={} fallback=ts reason=session-init-failed language={:?} direction={:?} error={}",
self.policy_label(),
lang,
opts.direction,
e
);
return self.fallback.impact_from_symbols(changed, lang, opts);
}
};
sess.probe_update();
if self.cfg.dump_capabilities {
eprintln!(
"{}",
serde_json::to_string(&sess.capabilities).unwrap_or_else(|_| "{}".to_string())
);
}
if sess.capabilities.call_hierarchy {
let out = lsp_impact_bfs(&mut sess, changed.to_vec(), opts);
match out {
Ok(o) if !o.impacted_symbols.is_empty() || changed.is_empty() => Ok(o),
Ok(o_empty) => {
if let Ok((index, refs)) = lsp_build_project_graph(&mut sess) {
return Ok(crate::impact::compute_impact(changed, &index, &refs, opts));
}
Ok(o_empty)
}
Err(_) => {
if let Ok((index, refs)) = lsp_build_project_graph(&mut sess) {
return Ok(crate::impact::compute_impact(changed, &index, &refs, opts));
}
anyhow::bail!("lsp impact_from_symbols failed")
}
}
} else if sess.capabilities.references || sess.capabilities.definition {
lsp_impact_references_definition(&mut sess, changed.to_vec(), opts)
} else if self.cfg.lsp_strict {
Err(strict_impact_capability_error(
lang,
opts.direction,
&sess.capabilities,
true,
self.policy_label(),
))
} else {
self.log_capability_fallback_impact(
lang,
opts.direction,
&sess.capabilities,
"missing-impact-capabilities",
);
self.fallback.impact_from_symbols(changed, lang, opts)
}
}
}
fn item_to_symbol(item: &serde_json::Value) -> Option<crate::ir::Symbol> {
let obj = if item.get("name").is_some() {
item
} else if let Some(from) = item.get("from") {
from
} else if let Some(to) = item.get("to") {
to
} else {
item
};
let name = obj.get("name")?.as_str()?.to_string();
let kind = map_lsp_symbol_kind(obj.get("kind")?.as_u64().unwrap_or(12));
let uri = obj.get("uri").and_then(|v| v.as_str())?;
let file = uri_to_path(uri);
let profile = profile_for_path(&file)?;
let range_v = obj.get("selectionRange").or_else(|| obj.get("range"))?;
let sl = range_v
.get("start")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32
+ 1;
let el = range_v
.get("end")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
Some(crate::ir::Symbol {
id: crate::ir::SymbolId::new(profile.symbol_lang, &file, &kind, &name, sl),
name,
kind,
file,
range: crate::ir::TextRange {
start_line: sl,
end_line: el.max(sl),
},
language: profile.symbol_lang.to_string(),
})
}
fn lsp_impact_bfs(
sess: &mut LspSession,
changed: Vec<crate::ir::Symbol>,
opts: &crate::impact::ImpactOptions,
) -> anyhow::Result<crate::impact::ImpactOutput> {
use std::collections::{HashSet, VecDeque};
let mut q: VecDeque<(serde_json::Value, usize)> = VecDeque::new();
let mut seen_keys: HashSet<String> = HashSet::new();
let mut node_map: std::collections::HashMap<String, crate::ir::Symbol> =
std::collections::HashMap::new();
let mut edges: Vec<crate::ir::reference::Reference> = Vec::new();
let mut summary_depth_by_symbol_id: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut seeded_roots = 0usize;
for s in changed.iter() {
let Some(profile) = profile_for_path(&s.file) else {
continue;
};
let abspath =
std::fs::canonicalize(&s.file).unwrap_or_else(|_| std::path::PathBuf::from(&s.file));
let uri = path_to_uri(&abspath);
if !sess._cfg.mock
&& let Ok(text) = std::fs::read_to_string(&abspath)
{
let _ = sess.ensure_did_open(&uri, profile.lsp_language_id, &text);
}
if matches!(
s.kind,
crate::ir::SymbolKind::Function | crate::ir::SymbolKind::Method
) {
let (line0, ch0) = guess_callable_position(&s.file, s)
.unwrap_or((s.range.start_line.saturating_sub(1), 0));
let mut roots = sess
.req_prepare_call_hierarchy(&uri, line0, ch0)
.unwrap_or_default();
if roots.is_empty() && ch0 != 0 {
roots = sess
.req_prepare_call_hierarchy(&uri, line0, 0)
.unwrap_or_default();
}
if roots.is_empty()
&& let Ok(defs) = sess.req_definition(&uri, line0, ch0)
&& let Some(loc) = defs.first()
&& let Some(r) = loc.get("targetSelectionRange").or_else(|| loc.get("range"))
&& let (Some(sl), Some(sc)) = (
r.get("start")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64()),
r.get("start")
.and_then(|s| s.get("character"))
.and_then(|n| n.as_u64()),
)
{
let dl0 = sl as u32;
let dc0 = sc as u32;
roots = sess
.req_prepare_call_hierarchy(&uri, dl0, dc0)
.unwrap_or_default();
if roots.is_empty() && dc0 != 0 {
roots = sess
.req_prepare_call_hierarchy(&uri, dl0, 0)
.unwrap_or_default();
}
}
for it in roots {
let key = format!(
"{}:{}:{}",
it.get("uri").and_then(|u| u.as_str()).unwrap_or(""),
it.get("name").and_then(|n| n.as_str()).unwrap_or(""),
it.get("kind").and_then(|k| k.as_u64()).unwrap_or(0)
);
if seen_keys.insert(key) {
q.push_back((it, 0));
seeded_roots += 1;
}
}
} else {
let defs = sess
.req_definition(&uri, s.range.start_line.saturating_sub(1), 0)
.unwrap_or_default();
let (def_uri, def_line0, def_ch0) = if let Some(loc) = defs.first() {
let u = loc
.get("uri")
.or_else(|| loc.get("targetUri"))
.and_then(|v| v.as_str())
.unwrap_or(&uri)
.to_string();
let r = loc.get("targetSelectionRange").or_else(|| loc.get("range"));
let l0 = r
.and_then(|rr| rr.get("start"))
.and_then(|st| st.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
let c0 = r
.and_then(|rr| rr.get("start"))
.and_then(|st| st.get("character"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
(u, l0, c0)
} else {
(uri.clone(), s.range.start_line.saturating_sub(1), 0)
};
let refs = sess
.req_references(&def_uri, def_line0, def_ch0)
.unwrap_or_default();
for loc in refs {
let loc_uri = loc.get("uri").and_then(|v| v.as_str()).unwrap_or("");
let file = uri_to_path(loc_uri);
let line0 = loc
.get("range")
.and_then(|r| r.get("start"))
.and_then(|st| st.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
let items = sess.req_document_symbol(loc_uri).unwrap_or_default();
if let Some(caller) = enclosing_symbol_in_doc(&items, &file, line0)
&& matches!(
caller.kind,
crate::ir::SymbolKind::Function | crate::ir::SymbolKind::Method
)
{
let c_abs = std::fs::canonicalize(&caller.file)
.unwrap_or_else(|_| std::path::PathBuf::from(&caller.file));
let c_uri = path_to_uri(&c_abs);
let (l0, ch0) = guess_callable_position(&caller.file, &caller)
.unwrap_or((caller.range.start_line.saturating_sub(1), 0));
let mut roots = sess
.req_prepare_call_hierarchy(&c_uri, l0, ch0)
.unwrap_or_default();
if roots.is_empty() && ch0 != 0 {
roots = sess
.req_prepare_call_hierarchy(&c_uri, l0, 0)
.unwrap_or_default();
}
for it in roots {
let key = format!(
"{}:{}:{}",
it.get("uri").and_then(|u| u.as_str()).unwrap_or(""),
it.get("name").and_then(|n| n.as_str()).unwrap_or(""),
it.get("kind").and_then(|k| k.as_u64()).unwrap_or(0)
);
if seen_keys.insert(key) {
q.push_back((it, 0));
seeded_roots += 1;
}
}
}
}
}
}
if seeded_roots == 0 {
return Ok(build_impact_output(
changed,
Vec::new(),
Vec::new(),
summary_depth_by_symbol_id,
opts.with_edges.unwrap_or(false),
));
}
while let Some((item, d)) = q.pop_front() {
let cur_sym = if let Some(sym) = item_to_symbol(&item) {
sym
} else {
continue;
};
let cur_id = cur_sym.id.0.clone();
node_map.entry(cur_id.clone()).or_insert(cur_sym.clone());
if let Some(maxd) = opts.max_depth
&& d >= maxd
{
continue;
}
match opts.direction {
crate::impact::ImpactDirection::Callers => {
let mut env = EnqueueEnv {
q: &mut q,
edges: &mut edges,
seen_keys: &mut seen_keys,
node_map: &mut node_map,
summary_depth_by_symbol_id: &mut summary_depth_by_symbol_id,
};
let incoming = sess.req_incoming_calls(&item).unwrap_or_default();
for inc in &incoming {
if let Some(from) = inc.get("from") {
enqueue_edge(&mut env, from, &cur_sym, d + 1, true);
}
}
if incoming.is_empty() && d == 0 {
enqueue_callers_via_references(sess, &cur_sym, &mut env, d + 1);
}
}
crate::impact::ImpactDirection::Callees => {
let mut env = EnqueueEnv {
q: &mut q,
edges: &mut edges,
seen_keys: &mut seen_keys,
node_map: &mut node_map,
summary_depth_by_symbol_id: &mut summary_depth_by_symbol_id,
};
for out in sess.req_outgoing_calls(&item).unwrap_or_default() {
if let Some(to) = out.get("to") {
enqueue_edge(&mut env, to, &cur_sym, d + 1, false);
}
}
let _ = scan_and_enqueue_callees(sess, &cur_sym, &mut env, d + 1);
}
crate::impact::ImpactDirection::Both => {
let mut env = EnqueueEnv {
q: &mut q,
edges: &mut edges,
seen_keys: &mut seen_keys,
node_map: &mut node_map,
summary_depth_by_symbol_id: &mut summary_depth_by_symbol_id,
};
let incoming = sess.req_incoming_calls(&item).unwrap_or_default();
for inc in &incoming {
if let Some(from) = inc.get("from") {
enqueue_edge(&mut env, from, &cur_sym, d + 1, true);
}
}
if incoming.is_empty() && d == 0 {
enqueue_callers_via_references(sess, &cur_sym, &mut env, d + 1);
}
let mut env2 = EnqueueEnv {
q: &mut q,
edges: &mut edges,
seen_keys: &mut seen_keys,
node_map: &mut node_map,
summary_depth_by_symbol_id: &mut summary_depth_by_symbol_id,
};
for out in sess.req_outgoing_calls(&item).unwrap_or_default() {
if let Some(to) = out.get("to") {
enqueue_edge(&mut env2, to, &cur_sym, d + 1, false);
}
}
let _ = scan_and_enqueue_callees(sess, &cur_sym, &mut env2, d + 1);
}
}
}
let changed_ids: HashSet<String> = changed.iter().map(|s| s.id.0.clone()).collect();
let impacted_symbols: Vec<crate::ir::Symbol> = node_map
.values()
.filter(|s| !changed_ids.contains(&s.id.0))
.cloned()
.collect();
Ok(build_impact_output(
changed,
impacted_symbols,
edges,
summary_depth_by_symbol_id,
opts.with_edges.unwrap_or(false),
))
}
fn scan_and_enqueue_callees(
sess: &mut LspSession,
cur_sym: &crate::ir::Symbol,
env: &mut EnqueueEnv<'_>,
next_depth: usize,
) -> usize {
use std::io::Read;
let mut added = 0usize;
let path = std::path::Path::new(&cur_sym.file);
let abspath = if path.is_absolute() {
path.to_path_buf()
} else {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
};
let uri = path_to_uri(&abspath);
let mut s = String::new();
if let Ok(mut f) = std::fs::File::open(&abspath) {
let _ = f.read_to_string(&mut s);
}
if s.is_empty() {
return 0;
}
let start0 = cur_sym.range.start_line.saturating_sub(1) as usize;
let end0 = cur_sym.range.end_line.saturating_sub(1) as usize;
let lines: Vec<&str> = s.lines().collect();
let mut seen_names: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
for (li, line) in lines.iter().enumerate().take(end0 + 1).skip(start0) {
let bytes = line.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' {
let mut last_seg_start = i;
i += 1;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_alphanumeric() || c == b'_' {
i += 1;
continue;
}
if i + 1 < bytes.len() && c == b':' && bytes[i + 1] == b':' {
i += 2;
last_seg_start = i;
continue;
}
if c == b'.' {
i += 1;
last_seg_start = i;
continue;
}
break;
}
let mut j = i;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && bytes[j] == b'(' {
let name = &line[last_seg_start..i];
if !(name == "if"
|| name == "while"
|| name == "loop"
|| name == "match"
|| name == "for"
|| name == "return"
|| name == "fn"
|| name.ends_with('!'))
{
if name == cur_sym.name && (li as u32 + 1) == cur_sym.range.start_line {
i = j;
continue;
}
let line0 = li as u32;
let ch0 = last_seg_start as u32;
if seen_names.insert((line0, ch0)) {
if let Ok(defs) = sess.req_definition(&uri, line0, ch0) {
for loc in defs {
let u = loc
.get("uri")
.or_else(|| loc.get("targetUri"))
.and_then(|v| v.as_str())
.unwrap_or("");
let r = loc
.get("range")
.or_else(|| loc.get("targetSelectionRange"));
if u.is_empty() || r.is_none() {
continue;
}
let rs = r
.unwrap()
.get("start")
.and_then(|st| st.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0)
as u32;
let rc = r
.unwrap()
.get("start")
.and_then(|st| st.get("character"))
.and_then(|n| n.as_u64())
.unwrap_or(0)
as u32;
let mut roots = sess
.req_prepare_call_hierarchy(u, rs, rc)
.unwrap_or_default();
if roots.is_empty() {
roots = sess
.req_prepare_call_hierarchy(u, rs, 0)
.unwrap_or_default();
}
for it in roots {
let key = format!(
"{}:{}:{}",
it.get("uri").and_then(|uu| uu.as_str()).unwrap_or(""),
it.get("name").and_then(|n| n.as_str()).unwrap_or(""),
it.get("kind").and_then(|k| k.as_u64()).unwrap_or(0)
);
if env.seen_keys.insert(key) {
env.q.push_back((it.clone(), next_depth));
if let Some(sym_to) = item_to_symbol(&it) {
env.node_map
.entry(sym_to.id.0.clone())
.or_insert(sym_to.clone());
record_lsp_min_depth(
env.summary_depth_by_symbol_id,
&sym_to.id.0,
next_depth,
);
env.edges.push(crate::ir::reference::Reference {
from: cur_sym.id.clone(),
to: sym_to.id.clone(),
kind: crate::ir::reference::RefKind::Call,
file: cur_sym.file.clone(),
line: li as u32 + 1,
certainty: crate::ir::reference::EdgeCertainty::Confirmed,
provenance: crate::ir::reference::EdgeProvenance::CallGraph,
});
added += 1;
}
}
}
}
}
}
}
}
i = j;
continue;
}
i += 1;
}
}
added
}
fn enqueue_callers_via_references(
sess: &mut LspSession,
cur_sym: &crate::ir::Symbol,
env: &mut EnqueueEnv<'_>,
next_depth: usize,
) {
let uri = path_to_uri(std::path::Path::new(&cur_sym.file));
let (line0, ch0) = guess_callable_position(&cur_sym.file, cur_sym)
.unwrap_or((cur_sym.range.start_line.saturating_sub(1), 0));
let mut refs = sess.req_references(&uri, line0, ch0).unwrap_or_default();
let mut doc_items_cache: std::collections::HashMap<String, Vec<serde_json::Value>> =
std::collections::HashMap::new();
let mut seen_ref_sites: std::collections::HashSet<(String, u32)> =
std::collections::HashSet::new();
if refs.len() <= 1 {
let defs = sess.req_definition(&uri, line0, ch0).unwrap_or_default();
let (def_uri, def_line0, def_ch0) = if let Some(loc) = defs.first() {
let u = loc
.get("uri")
.or_else(|| loc.get("targetUri"))
.and_then(|v| v.as_str())
.unwrap_or(&uri)
.to_string();
let r = loc.get("targetSelectionRange").or_else(|| loc.get("range"));
let l0 = r
.and_then(|rr| rr.get("start"))
.and_then(|st| st.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
let c0 = r
.and_then(|rr| rr.get("start"))
.and_then(|st| st.get("character"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
(u, l0, c0)
} else {
(uri.clone(), line0, ch0)
};
let refs2 = sess
.req_references(&def_uri, def_line0, def_ch0)
.unwrap_or_default();
if refs2.len() > refs.len() || refs.is_empty() {
refs = refs2;
}
}
for loc in refs {
let loc_uri = loc.get("uri").and_then(|v| v.as_str()).unwrap_or("");
if loc_uri.is_empty() {
continue;
}
let file = uri_to_path(loc_uri);
let line0 = loc
.get("range")
.and_then(|r| r.get("start"))
.and_then(|st| st.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
if !seen_ref_sites.insert((loc_uri.to_string(), line0)) {
continue;
}
let items = if let Some(cached) = doc_items_cache.get(loc_uri) {
cached.clone()
} else {
let fetched = sess.req_document_symbol(loc_uri).unwrap_or_default();
doc_items_cache.insert(loc_uri.to_string(), fetched.clone());
fetched
};
if let Some(caller) = enclosing_symbol_in_doc(&items, &file, line0)
&& matches!(
caller.kind,
crate::ir::SymbolKind::Function | crate::ir::SymbolKind::Method
)
{
let c_abs = std::fs::canonicalize(&caller.file)
.unwrap_or_else(|_| std::path::PathBuf::from(&caller.file));
let c_uri = path_to_uri(&c_abs);
let (l0, ch0) = guess_callable_position(&caller.file, &caller)
.unwrap_or((caller.range.start_line.saturating_sub(1), 0));
let mut roots = sess
.req_prepare_call_hierarchy(&c_uri, l0, ch0)
.unwrap_or_default();
if roots.is_empty() && ch0 != 0 {
roots = sess
.req_prepare_call_hierarchy(&c_uri, l0, 0)
.unwrap_or_default();
}
for it in roots {
let key = format!(
"{}:{}:{}",
it.get("uri").and_then(|u| u.as_str()).unwrap_or(""),
it.get("name").and_then(|n| n.as_str()).unwrap_or(""),
it.get("kind").and_then(|k| k.as_u64()).unwrap_or(0)
);
if env.seen_keys.insert(key) {
env.q.push_back((it.clone(), next_depth));
if let Some(sym_from) = item_to_symbol(&it) {
env.node_map
.entry(sym_from.id.0.clone())
.or_insert(sym_from.clone());
record_lsp_min_depth(
env.summary_depth_by_symbol_id,
&sym_from.id.0,
next_depth,
);
env.edges.push(crate::ir::reference::Reference {
from: sym_from.id.clone(),
to: cur_sym.id.clone(),
kind: crate::ir::reference::RefKind::Call,
file: sym_from.file.clone(),
line: sym_from.range.start_line,
certainty: crate::ir::reference::EdgeCertainty::Confirmed,
provenance: crate::ir::reference::EdgeProvenance::CallGraph,
});
}
}
}
}
}
}
fn scan_callees_for_changed(
sess: &mut LspSession,
changed: &[crate::ir::Symbol],
) -> (Vec<crate::ir::Symbol>, Vec<crate::ir::reference::Reference>) {
let mut out_syms: Vec<crate::ir::Symbol> = Vec::new();
let mut out_edges: Vec<crate::ir::reference::Reference> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for s in changed.iter().filter(|s| {
matches!(
s.kind,
crate::ir::SymbolKind::Function | crate::ir::SymbolKind::Method
)
}) {
let (syms, edges) = scan_callees_symbols(sess, s);
for sym in syms {
if seen.insert(sym.id.0.clone()) {
out_syms.push(sym);
}
}
out_edges.extend(edges);
}
(out_syms, out_edges)
}
fn scan_callees_symbols(
sess: &mut LspSession,
cur_sym: &crate::ir::Symbol,
) -> (Vec<crate::ir::Symbol>, Vec<crate::ir::reference::Reference>) {
use std::io::Read;
let mut out_syms: Vec<crate::ir::Symbol> = Vec::new();
let mut out_edges: Vec<crate::ir::reference::Reference> = Vec::new();
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let path = std::path::Path::new(&cur_sym.file);
let abspath = if path.is_absolute() {
path.to_path_buf()
} else {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
};
let uri = path_to_uri(&abspath);
let mut s = String::new();
if let Ok(mut f) = std::fs::File::open(&abspath) {
let _ = f.read_to_string(&mut s);
}
if s.is_empty() {
return (out_syms, out_edges);
}
let start0 = cur_sym.range.start_line.saturating_sub(1) as usize;
let end0 = cur_sym.range.end_line.saturating_sub(1) as usize;
let lines: Vec<&str> = s.lines().collect();
for (li, line) in lines.iter().enumerate().take(end0 + 1).skip(start0) {
let bytes = line.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
if bytes[i].is_ascii_alphabetic() || bytes[i] == b'_' {
let mut last_seg_start = i;
i += 1;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_alphanumeric() || c == b'_' {
i += 1;
continue;
}
if i + 1 < bytes.len() && c == b':' && bytes[i + 1] == b':' {
i += 2;
last_seg_start = i;
continue;
}
if c == b'.' {
i += 1;
last_seg_start = i;
continue;
}
break;
}
let mut j = i;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && bytes[j] == b'(' {
let name = &line[last_seg_start..i];
if !(name == "if"
|| name == "while"
|| name == "loop"
|| name == "match"
|| name == "for"
|| name == "return"
|| name == "fn"
|| name.ends_with('!'))
{
if name == cur_sym.name && (li as u32 + 1) == cur_sym.range.start_line {
i = j;
continue;
}
let line0 = li as u32;
let ch0 = last_seg_start as u32;
let defs = sess.req_definition(&uri, line0, ch0).unwrap_or_default();
for loc in defs {
let def_uri = loc
.get("uri")
.or_else(|| loc.get("targetUri"))
.and_then(|v| v.as_str())
.unwrap_or("");
let def_file = uri_to_path(def_uri);
let r = loc.get("targetSelectionRange").or_else(|| loc.get("range"));
let def_l0 = r
.and_then(|rr| rr.get("start"))
.and_then(|st| st.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
let items = sess.req_document_symbol(def_uri).unwrap_or_default();
if let Some(sym_to) = enclosing_symbol_in_doc(&items, &def_file, def_l0)
&& (sym_to.id.0 != cur_sym.id.0)
&& matches!(
sym_to.kind,
crate::ir::SymbolKind::Function | crate::ir::SymbolKind::Method
)
&& seen_ids.insert(sym_to.id.0.clone())
{
out_syms.push(sym_to.clone());
out_edges.push(crate::ir::reference::Reference {
from: cur_sym.id.clone(),
to: sym_to.id.clone(),
kind: crate::ir::reference::RefKind::Call,
file: cur_sym.file.clone(),
line: li as u32 + 1,
certainty: crate::ir::reference::EdgeCertainty::Confirmed,
provenance: crate::ir::reference::EdgeProvenance::CallGraph,
});
}
}
}
}
i = j;
continue;
}
i += 1;
}
}
(out_syms, out_edges)
}
fn guess_callable_position(file: &str, sym: &crate::ir::Symbol) -> Option<(u32, u32)> {
use std::io::Read;
let mut f = std::fs::File::open(file).ok()?;
let mut s = String::new();
f.read_to_string(&mut s).ok()?;
let line_idx = (sym.range.start_line.saturating_sub(1)) as usize;
let line = s.lines().nth(line_idx)?;
if let Some(pos) = line.find(&sym.name) {
return Some((sym.range.start_line.saturating_sub(1), pos as u32));
}
let pat = format!("fn {}", sym.name);
if let Some(pos) = line.find(&pat) {
return Some((sym.range.start_line.saturating_sub(1), (pos + 3) as u32));
}
Some((sym.range.start_line.saturating_sub(1), 0))
}
struct EnqueueEnv<'a> {
q: &'a mut std::collections::VecDeque<(serde_json::Value, usize)>,
edges: &'a mut Vec<crate::ir::reference::Reference>,
seen_keys: &'a mut std::collections::HashSet<String>,
node_map: &'a mut std::collections::HashMap<String, crate::ir::Symbol>,
summary_depth_by_symbol_id: &'a mut std::collections::HashMap<String, usize>,
}
#[derive(Default)]
struct LspImpactBuild {
impacted_symbols: Vec<crate::ir::Symbol>,
edges: Vec<crate::ir::reference::Reference>,
summary_depth_by_symbol_id: std::collections::HashMap<String, usize>,
}
fn record_lsp_min_depth(
min_depth_by_symbol_id: &mut std::collections::HashMap<String, usize>,
symbol_id: &str,
depth: usize,
) {
min_depth_by_symbol_id
.entry(symbol_id.to_string())
.and_modify(|current| *current = (*current).min(depth))
.or_insert(depth);
}
fn enqueue_edge(
env: &mut EnqueueEnv,
next_item: &serde_json::Value,
cur_sym: &crate::ir::Symbol,
next_depth: usize,
is_incoming: bool,
) {
if let Some(sym) = item_to_symbol(next_item) {
let key = sym.id.0.clone();
if env.seen_keys.insert(key.clone()) {
env.q.push_back((next_item.clone(), next_depth));
}
env.node_map.entry(key.clone()).or_insert(sym.clone());
record_lsp_min_depth(env.summary_depth_by_symbol_id, &key, next_depth);
let (from, to) = if is_incoming {
(sym.clone(), cur_sym.clone())
} else {
(cur_sym.clone(), sym.clone())
};
env.edges.push(crate::ir::reference::Reference {
from: from.id.clone(),
to: to.id.clone(),
kind: crate::ir::reference::RefKind::Call,
file: from.file.clone(),
line: from.range.start_line,
certainty: crate::ir::reference::EdgeCertainty::Confirmed,
provenance: crate::ir::reference::EdgeProvenance::CallGraph,
});
}
}
fn build_impact_output(
changed: Vec<crate::ir::Symbol>,
impacted_symbols: Vec<crate::ir::Symbol>,
mut edges: Vec<crate::ir::reference::Reference>,
summary_depth_by_symbol_id: std::collections::HashMap<String, usize>,
with_edges: bool,
) -> crate::impact::ImpactOutput {
if !with_edges {
edges.clear();
} else {
edges.sort_by(|a, b| {
a.from
.0
.cmp(&b.from.0)
.then_with(|| a.to.0.cmp(&b.to.0))
.then_with(|| a.line.cmp(&b.line))
});
edges.dedup_by(|a, b| a.from.0 == b.from.0 && a.to.0 == b.to.0 && a.line == b.line);
}
crate::impact::finalize_impact_output(
changed,
impacted_symbols,
edges,
&summary_depth_by_symbol_id,
std::collections::HashMap::new(),
)
}
fn lsp_impact_references_build(
sess: &mut LspSession,
changed: &[crate::ir::Symbol],
opts: &crate::impact::ImpactOptions,
) -> anyhow::Result<LspImpactBuild> {
use std::collections::{HashMap, HashSet, VecDeque};
let mut q: VecDeque<(crate::ir::Symbol, usize)> = VecDeque::new();
let mut seen_edges: HashSet<String> = HashSet::new();
let mut queued_nodes: HashSet<String> = HashSet::new();
let mut node_map: std::collections::HashMap<String, crate::ir::Symbol> =
std::collections::HashMap::new();
let mut edges: Vec<crate::ir::reference::Reference> = Vec::new();
let mut doc_items_cache: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
let mut summary_depth_by_symbol_id: HashMap<String, usize> = HashMap::new();
for s in changed.iter() {
q.push_back((s.clone(), 0));
node_map.insert(s.id.0.clone(), s.clone());
queued_nodes.insert(s.id.0.clone());
}
while let Some((sym, d)) = q.pop_front() {
if let Some(maxd) = opts.max_depth
&& d >= maxd
{
continue;
}
let uri = path_to_uri(std::path::Path::new(&sym.file));
let (line0, ch0) = guess_callable_position(&sym.file, &sym)
.unwrap_or((sym.range.start_line.saturating_sub(1), 0));
let defs = sess.req_definition(&uri, line0, ch0).unwrap_or_default();
let (def_uri, def_line0, def_ch0) = if let Some(loc) = defs.first() {
let u = loc
.get("uri")
.or_else(|| loc.get("targetUri"))
.and_then(|v| v.as_str())
.unwrap_or(&uri)
.to_string();
let r = loc.get("targetSelectionRange").or_else(|| loc.get("range"));
let l0 = r
.and_then(|rr| rr.get("start"))
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
let c0 = r
.and_then(|rr| rr.get("start"))
.and_then(|s| s.get("character"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
(u, l0, c0)
} else {
(uri.clone(), sym.range.start_line.saturating_sub(1), 0)
};
let refs = sess
.req_references(&def_uri, def_line0, def_ch0)
.unwrap_or_default();
let mut seen_ref_sites: HashSet<(String, u32)> = HashSet::new();
for loc in refs {
let loc_uri = loc.get("uri").and_then(|v| v.as_str()).unwrap_or("");
if loc_uri.is_empty() {
continue;
}
let file = uri_to_path(loc_uri);
let line0 = loc
.get("range")
.and_then(|r| r.get("start"))
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
if !seen_ref_sites.insert((loc_uri.to_string(), line0)) {
continue;
}
let items = if let Some(cached) = doc_items_cache.get(loc_uri) {
cached.clone()
} else {
let fetched = sess.req_document_symbol(loc_uri).unwrap_or_default();
doc_items_cache.insert(loc_uri.to_string(), fetched.clone());
fetched
};
if let Some(caller) = enclosing_symbol_in_doc(&items, &file, line0)
&& matches!(
caller.kind,
crate::ir::SymbolKind::Function | crate::ir::SymbolKind::Method
)
{
let key = caller.id.0.clone();
node_map.entry(key.clone()).or_insert(caller.clone());
record_lsp_min_depth(&mut summary_depth_by_symbol_id, &key, d + 1);
if seen_edges.insert(format!("edge:{}->{}", caller.id.0, sym.id.0)) {
edges.push(crate::ir::reference::Reference {
from: caller.id.clone(),
to: sym.id.clone(),
kind: crate::ir::reference::RefKind::Call,
file: caller.file.clone(),
line: caller.range.start_line,
certainty: crate::ir::reference::EdgeCertainty::Confirmed,
provenance: crate::ir::reference::EdgeProvenance::CallGraph,
});
}
if queued_nodes.insert(caller.id.0.clone()) {
q.push_back((caller, d + 1));
}
}
}
}
let changed_ids: HashSet<String> = changed.iter().map(|s| s.id.0.clone()).collect();
let impacted_symbols: Vec<crate::ir::Symbol> = node_map
.values()
.filter(|s| !changed_ids.contains(&s.id.0))
.cloned()
.collect();
Ok(LspImpactBuild {
impacted_symbols,
edges,
summary_depth_by_symbol_id,
})
}
fn lsp_impact_references(
sess: &mut LspSession,
changed: Vec<crate::ir::Symbol>,
opts: &crate::impact::ImpactOptions,
) -> anyhow::Result<crate::impact::ImpactOutput> {
let build = lsp_impact_references_build(sess, &changed, opts)?;
Ok(build_impact_output(
changed,
build.impacted_symbols,
build.edges,
build.summary_depth_by_symbol_id,
opts.with_edges.unwrap_or(false),
))
}
fn lsp_impact_references_definition(
sess: &mut LspSession,
changed: Vec<crate::ir::Symbol>,
opts: &crate::impact::ImpactOptions,
) -> anyhow::Result<crate::impact::ImpactOutput> {
if sess._cfg.mock {
let (index, refs) = crate::impact::build_project_graph()?;
return Ok(crate::impact::compute_impact(&changed, &index, &refs, opts));
}
let want_callers = matches!(
opts.direction,
crate::impact::ImpactDirection::Callers | crate::impact::ImpactDirection::Both
);
let want_callees = matches!(
opts.direction,
crate::impact::ImpactDirection::Callees | crate::impact::ImpactDirection::Both
);
let mut impacted_symbols: Vec<crate::ir::Symbol> = Vec::new();
let mut edges: Vec<crate::ir::reference::Reference> = Vec::new();
let mut summary_depth_by_symbol_id: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
if want_callers {
let callers_build = lsp_impact_references_build(sess, &changed, opts)?;
impacted_symbols.extend(callers_build.impacted_symbols);
edges.extend(callers_build.edges);
for (symbol_id, depth) in callers_build.summary_depth_by_symbol_id {
record_lsp_min_depth(&mut summary_depth_by_symbol_id, &symbol_id, depth);
}
}
if want_callees {
let (callees, callee_edges) = scan_callees_for_changed(sess, &changed);
for sym in &callees {
record_lsp_min_depth(&mut summary_depth_by_symbol_id, &sym.id.0, 1);
}
impacted_symbols.extend(callees);
edges.extend(callee_edges);
}
Ok(build_impact_output(
changed,
impacted_symbols,
edges,
summary_depth_by_symbol_id,
opts.with_edges.unwrap_or(false),
))
}
fn collect_enclosing_candidates(
items: &[serde_json::Value],
file: &str,
symbol_lang: &str,
line0: u32,
out: &mut Vec<crate::ir::Symbol>,
) {
for it in items {
if let Some(children) = it.get("children").and_then(|v| v.as_array()) {
collect_enclosing_candidates(children, file, symbol_lang, line0, out);
}
if let Some(r) = it.get("range")
&& let (Some(s), Some(e)) = (r.get("start"), r.get("end"))
{
let sl = s.get("line").and_then(|n| n.as_u64()).unwrap_or(0) as u32;
let el = e.get("line").and_then(|n| n.as_u64()).unwrap_or(0) as u32;
if sl <= line0 && line0 <= el {
let name = it.get("name").and_then(|v| v.as_str()).unwrap_or("");
let kind =
map_lsp_symbol_kind(it.get("kind").and_then(|v| v.as_u64()).unwrap_or(12));
out.push(crate::ir::Symbol {
id: crate::ir::SymbolId::new(symbol_lang, file, &kind, name, sl + 1),
name: name.to_string(),
kind,
file: file.to_string(),
range: crate::ir::TextRange {
start_line: sl + 1,
end_line: el.max(sl) + 1,
},
language: symbol_lang.to_string(),
});
}
}
if let Some(loc) = it.get("location")
&& let Some(r) = loc.get("range")
{
let sl = r
.get("start")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
let el = r
.get("end")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
if sl <= line0 && line0 <= el {
let name = it.get("name").and_then(|v| v.as_str()).unwrap_or("");
let kind =
map_lsp_symbol_kind(it.get("kind").and_then(|v| v.as_u64()).unwrap_or(12));
out.push(crate::ir::Symbol {
id: crate::ir::SymbolId::new(symbol_lang, file, &kind, name, sl + 1),
name: name.to_string(),
kind,
file: file.to_string(),
range: crate::ir::TextRange {
start_line: sl + 1,
end_line: el.max(sl) + 1,
},
language: symbol_lang.to_string(),
});
}
}
}
}
fn enclosing_symbol_in_doc(
items: &[serde_json::Value],
file: &str,
line0: u32,
) -> Option<crate::ir::Symbol> {
let profile = profile_for_path(file)?;
let mut candidates = Vec::new();
collect_enclosing_candidates(items, file, profile.symbol_lang, line0, &mut candidates);
candidates.into_iter().min_by(|a, b| {
let a_span = a.range.end_line.saturating_sub(a.range.start_line);
let b_span = b.range.end_line.saturating_sub(b.range.start_line);
a_span
.cmp(&b_span)
.then_with(|| b.range.start_line.cmp(&a.range.start_line))
})
}
fn lsp_changed_symbols(
sess: &mut LspSession,
diffs: &[crate::FileChanges],
lang: crate::mapping::LanguageMode,
) -> anyhow::Result<crate::mapping::ChangedOutput> {
use std::collections::{HashMap, HashSet};
if sess._cfg.mock {
return crate::mapping::compute_changed_symbols(diffs, lang);
}
let mut changed_files: Vec<String> = Vec::new();
let mut changed_lines_by_file: HashMap<String, HashSet<u32>> = HashMap::new();
for fc in diffs.iter() {
if let Some(path) = &fc.new_path {
changed_files.push(path.clone());
}
if let Some(path) = &fc.new_path {
let set = changed_lines_by_file.entry(path.clone()).or_default();
for ch in &fc.changes {
if let Some(nl) = ch.new_line
&& (matches!(ch.kind, crate::diff::ChangeKind::Added)
|| matches!(ch.kind, crate::diff::ChangeKind::Context))
{
set.insert(nl);
}
}
}
}
changed_files.sort();
changed_files.dedup();
let mut symbols: Vec<crate::ir::Symbol> = Vec::new();
for (path, lines) in changed_lines_by_file.iter() {
if !path_matches_mode(path, lang) {
continue;
}
let Some(profile) = profile_for_path_or_mode(path, lang) else {
continue;
};
let Some(language_id) = did_open_language_id_for_path(path, lang) else {
continue;
};
let abspath = std::fs::canonicalize(path).unwrap_or(std::path::PathBuf::from(path));
let uri = path_to_uri(&abspath);
let text = std::fs::read_to_string(&abspath).unwrap_or_else(|_| String::new());
let _ = sess.ensure_did_open(&uri, language_id, &text);
if let Ok(items) = sess.req_document_symbol(&uri) {
for item in &items {
collect_symbols_from_item(path, profile.symbol_lang, item, &mut symbols, lines);
}
}
}
symbols.sort_by(|a, b| a.id.0.cmp(&b.id.0));
symbols.dedup_by(|a, b| a.id.0 == b.id.0);
Ok(crate::mapping::ChangedOutput {
changed_files,
changed_symbols: symbols,
})
}
fn path_to_uri(p: &std::path::Path) -> String {
let mut s = String::from("file://");
let ps = p
.canonicalize()
.unwrap_or_else(|_| p.to_path_buf())
.to_string_lossy()
.replace(' ', "%20");
if cfg!(target_os = "windows") {
s.push_str(&ps.replace('\\', "/"));
} else {
s.push_str(&ps);
}
s
}
fn uri_to_path(uri: &str) -> String {
let raw = if let Some(rest) = uri.strip_prefix("file://") {
rest.replace("%20", " ")
} else {
uri.to_string()
};
match std::env::current_dir() {
Ok(cwd) => {
let cwd = cwd.canonicalize().unwrap_or(cwd);
let rawp = std::path::Path::new(&raw);
if rawp.is_absolute() {
if let Ok(stripped) = rawp
.canonicalize()
.unwrap_or_else(|_| rawp.to_path_buf())
.strip_prefix(&cwd)
{
let s = stripped.to_string_lossy().to_string();
if s.is_empty() { ".".to_string() } else { s }
} else {
raw
}
} else {
raw
}
}
Err(_) => raw,
}
}
fn collect_symbols_from_item(
path: &str,
symbol_lang: &str,
item: &serde_json::Value,
out: &mut Vec<crate::ir::Symbol>,
changed_lines: &std::collections::HashSet<u32>,
) {
let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
let kind_num = item.get("kind").and_then(|v| v.as_u64()).unwrap_or(12);
let allowed = matches!(kind_num, 2 | 5 | 6 | 9 | 10 | 12 | 23);
let (start_line0, end_line0) = if let Some(r) = item.get("range") {
(
r.get("start")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0),
r.get("end")
.and_then(|e| e.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0),
)
} else if let Some(loc) = item.get("location") {
if let Some(r) = loc.get("range") {
(
r.get("start")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0),
r.get("end")
.and_then(|e| e.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0),
)
} else {
(0, 0)
}
} else {
(0, 0)
};
let start_line = (start_line0 as u32) + 1;
let mut end_line = end_line0 as u32; if end_line < start_line {
end_line = start_line;
}
let kind = map_lsp_symbol_kind(kind_num);
if allowed && !name.is_empty() && intersects_lines(start_line, end_line, changed_lines) {
out.push(crate::ir::Symbol {
id: crate::ir::SymbolId::new(symbol_lang, path, &kind, name, start_line),
name: name.to_string(),
kind,
file: path.to_string(),
range: crate::ir::TextRange {
start_line,
end_line,
},
language: symbol_lang.to_string(),
});
}
if let Some(children) = item.get("children").and_then(|v| v.as_array()) {
for ch in children {
collect_symbols_from_item(path, symbol_lang, ch, out, changed_lines);
}
}
}
fn map_lsp_symbol_kind(k: u64) -> crate::ir::SymbolKind {
match k {
6 => crate::ir::SymbolKind::Method, 12 => crate::ir::SymbolKind::Function, 5 => crate::ir::SymbolKind::Struct, 23 => crate::ir::SymbolKind::Struct, 10 => crate::ir::SymbolKind::Enum, 22 => crate::ir::SymbolKind::Enum, 9 => crate::ir::SymbolKind::Trait, 2 => crate::ir::SymbolKind::Module, _ => crate::ir::SymbolKind::Function,
}
}
fn intersects_lines(start: u32, end: u32, lines: &std::collections::HashSet<u32>) -> bool {
let mut ln = start;
while ln <= end {
if lines.contains(&ln) {
return true;
}
ln += 1;
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangedStrategy {
DocumentSymbol,
WorkspaceSymbol,
TsFallback,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImpactStrategy {
CallHierarchy,
References,
TsFallback,
}
pub fn decide_changed_strategy(caps: &CapabilityMatrix) -> ChangedStrategy {
if caps.document_symbol {
ChangedStrategy::DocumentSymbol
} else if caps.workspace_symbol {
ChangedStrategy::WorkspaceSymbol
} else {
ChangedStrategy::TsFallback
}
}
pub fn decide_impact_strategy(caps: &CapabilityMatrix) -> ImpactStrategy {
if caps.call_hierarchy {
ImpactStrategy::CallHierarchy
} else if caps.references || caps.definition {
ImpactStrategy::References
} else {
ImpactStrategy::TsFallback
}
}
fn collect_symbols_all(
path: &str,
symbol_lang: &str,
items: &[serde_json::Value],
out: &mut Vec<crate::ir::Symbol>,
) {
for it in items {
let name = it.get("name").and_then(|v| v.as_str()).unwrap_or("");
let kind_num = it.get("kind").and_then(|v| v.as_u64()).unwrap_or(12);
let allowed = matches!(kind_num, 6 | 12); let (start_line0, end_line0) = if let Some(r) = it.get("range") {
(
r.get("start")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0),
r.get("end")
.and_then(|e| e.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0),
)
} else if let Some(loc) = it.get("location") {
if let Some(r) = loc.get("range") {
(
r.get("start")
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0),
r.get("end")
.and_then(|e| e.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0),
)
} else {
(0, 0)
}
} else {
(0, 0)
};
let start_line = (start_line0 as u32) + 1;
let mut end_line = end_line0 as u32;
if end_line < start_line {
end_line = start_line;
}
let kind = map_lsp_symbol_kind(kind_num);
if allowed && !name.is_empty() {
out.push(crate::ir::Symbol {
id: crate::ir::SymbolId::new(symbol_lang, path, &kind, name, start_line),
name: name.to_string(),
kind,
file: path.to_string(),
range: crate::ir::TextRange {
start_line,
end_line,
},
language: symbol_lang.to_string(),
});
}
if let Some(children) = it.get("children").and_then(|v| v.as_array()) {
collect_symbols_all(path, symbol_lang, children, out);
}
}
}
fn lsp_build_project_graph(
sess: &mut LspSession,
) -> anyhow::Result<(
crate::ir::reference::SymbolIndex,
Vec<crate::ir::reference::Reference>,
)> {
use walkdir::WalkDir;
let mut all_symbols: Vec<crate::ir::Symbol> = Vec::new();
for entry in WalkDir::new(".")
.into_iter()
.filter_entry(|e| {
let p = e.path();
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
!(name == ".git" || name == "target" || name.starts_with('.'))
})
.filter_map(Result::ok)
{
let path = entry.path();
if path.is_file() {
let path_str = if let Ok(rel) = path.strip_prefix("./") {
rel.to_string_lossy().to_string()
} else {
path.to_string_lossy().to_string()
};
let Some(profile) = profile_for_path(&path_str) else {
continue;
};
let abspath = std::fs::canonicalize(path).unwrap_or(path.to_path_buf());
let uri = path_to_uri(&abspath);
let text = std::fs::read_to_string(&abspath).unwrap_or_default();
let _ = sess.ensure_did_open(&uri, profile.lsp_language_id, &text);
if let Ok(items) = sess.req_document_symbol(&uri) {
collect_symbols_all(&path_str, profile.symbol_lang, &items, &mut all_symbols);
}
}
}
all_symbols.sort_by(|a, b| a.id.0.cmp(&b.id.0));
all_symbols.dedup_by(|a, b| a.id.0 == b.id.0);
let mut edges: Vec<crate::ir::reference::Reference> = Vec::new();
for to_sym in &all_symbols {
let abspath = std::fs::canonicalize(&to_sym.file)
.unwrap_or_else(|_| std::path::PathBuf::from(&to_sym.file));
let uri = path_to_uri(&abspath);
let (line0, ch0) = guess_callable_position(&to_sym.file, to_sym)
.unwrap_or((to_sym.range.start_line.saturating_sub(1), 0));
let refs = sess.req_references(&uri, line0, ch0).unwrap_or_default();
for loc in refs {
let loc_uri = loc.get("uri").and_then(|v| v.as_str()).unwrap_or("");
let file = uri_to_path(loc_uri);
let line0 = loc
.get("range")
.and_then(|r| r.get("start"))
.and_then(|s| s.get("line"))
.and_then(|n| n.as_u64())
.unwrap_or(0) as u32;
let items = sess.req_document_symbol(loc_uri).unwrap_or_default();
if let Some(caller) = enclosing_symbol_in_doc(&items, &file, line0)
&& caller.id.0 != to_sym.id.0
&& matches!(
caller.kind,
crate::ir::SymbolKind::Function | crate::ir::SymbolKind::Method
)
{
edges.push(crate::ir::reference::Reference {
from: caller.id.clone(),
to: to_sym.id.clone(),
kind: crate::ir::reference::RefKind::Call,
file: caller.file.clone(),
line: caller.range.start_line,
certainty: crate::ir::reference::EdgeCertainty::Confirmed,
provenance: crate::ir::reference::EdgeProvenance::CallGraph,
});
}
}
}
if edges.is_empty() {
trace!("lsp: graph references empty, fallback to callsite scan");
let id_set: std::collections::HashSet<String> =
all_symbols.iter().map(|s| s.id.0.clone()).collect();
for from_sym in &all_symbols {
let (_callees, extra_edges) = scan_callees_symbols(sess, from_sym);
for e in extra_edges {
if id_set.contains(&e.from.0) && id_set.contains(&e.to.0) {
edges.push(e);
}
}
}
}
edges.sort_by(|a, b| {
a.from
.0
.cmp(&b.from.0)
.then_with(|| a.to.0.cmp(&b.to.0))
.then_with(|| a.line.cmp(&b.line))
});
edges.dedup_by(|a, b| a.from.0 == b.from.0 && a.to.0 == b.to.0);
let index = crate::ir::reference::SymbolIndex::build(all_symbols);
Ok((index, edges))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jsonrpc_framing_roundtrip() {
let v = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"capabilities": {}}
});
let buf = encode_jsonrpc_message(&v);
let (v2, used) = decode_jsonrpc_message(&buf).expect("decode");
assert_eq!(v2["method"], "initialize");
assert_eq!(used, buf.len());
}
#[test]
fn jsonrpc_decode_with_extra_data() {
let v = json!({"jsonrpc":"2.0","id":1,"result":{"ok":true}});
let mut buf = encode_jsonrpc_message(&v);
buf.extend_from_slice(b"trailing");
let (v2, used) = decode_jsonrpc_message(&buf).expect("decode");
assert_eq!(v2["result"]["ok"], true);
assert!(used < buf.len());
}
#[test]
fn mock_session_probes_are_true() {
let cfg = LspConfig {
strict: true,
dump_capabilities: false,
mock: true,
mock_caps: None,
};
let sess = LspSession::new(crate::mapping::LanguageMode::Rust, cfg).expect("mock ok");
assert!(sess.capabilities.document_symbol);
assert!(sess.capabilities.call_hierarchy);
}
#[test]
fn probe_update_non_mock_without_io_disables_caps() {
let mut sess = LspSession {
_cfg: LspConfig {
strict: true,
dump_capabilities: false,
mock: false,
mock_caps: None,
},
capabilities: CapabilityMatrix {
call_hierarchy: true,
references: true,
definition: true,
document_symbol: true,
workspace_symbol: true,
},
child: None,
stdin: None,
stdout: None,
next_id: std::sync::atomic::AtomicU64::new(1),
doc_symbol_cache: std::collections::HashMap::new(),
prepare_call_hierarchy_cache: std::collections::HashMap::new(),
definition_cache: std::collections::HashMap::new(),
references_cache: std::collections::HashMap::new(),
incoming_calls_cache: std::collections::HashMap::new(),
outgoing_calls_cache: std::collections::HashMap::new(),
opened_docs: std::collections::HashSet::new(),
};
sess.probe_update();
assert!(!sess.capabilities.call_hierarchy);
assert!(!sess.capabilities.references);
assert!(!sess.capabilities.definition);
assert!(!sess.capabilities.document_symbol);
assert!(!sess.capabilities.workspace_symbol);
}
#[test]
fn probe_update_mock_caps_filter_still_applies() {
let cfg = LspConfig {
strict: true,
dump_capabilities: false,
mock: true,
mock_caps: Some(crate::engine::CapsHint {
call_hierarchy: false,
references: true,
definition: false,
document_symbol: true,
workspace_symbol: false,
}),
};
let mut sess = LspSession::new(crate::mapping::LanguageMode::Rust, cfg).expect("mock ok");
sess.probe_update();
assert!(!sess.capabilities.call_hierarchy);
assert!(sess.capabilities.references);
assert!(!sess.capabilities.definition);
assert!(sess.capabilities.document_symbol);
assert!(!sess.capabilities.workspace_symbol);
}
#[test]
fn lsp_engine_policy_label_reflects_auto_policy_context() {
let cfg = crate::engine::EngineConfig {
lsp_strict: false,
dump_capabilities: false,
mock_lsp: true,
mock_caps: None,
};
let e = LspEngine::new_with_auto_policy(
cfg,
Some(crate::engine::AutoPolicy::StrictIfAvailable),
);
assert_eq!(e.policy_label(), "strict-if-available");
}
#[test]
fn strict_capability_errors_include_policy_label() {
let caps = CapabilityMatrix {
document_symbol: false,
workspace_symbol: false,
call_hierarchy: false,
references: false,
definition: false,
};
let e1 = strict_changed_capability_error(LanguageMode::Python, &caps, "strict");
let s1 = format!("{e1}");
assert!(s1.contains("policy=strict"));
let e2 = strict_impact_capability_error(
LanguageMode::Python,
crate::impact::ImpactDirection::Both,
&caps,
true,
"strict",
);
let s2 = format!("{e2}");
assert!(s2.contains("policy=strict"));
}
#[test]
fn server_command_for_mode_includes_python_go_java() {
assert_eq!(
server_command_for_mode(LanguageMode::Python),
detect_python_lsp_server()
);
assert_eq!(
server_command_for_mode(LanguageMode::Go),
Some(("gopls", vec![]))
);
assert_eq!(
server_command_for_mode(LanguageMode::Java),
Some(("jdtls", vec![]))
);
}
#[test]
fn session_mode_for_files_auto_selects_go_java_servers() {
let go_files = vec!["cmd/main.go".to_string(), "pkg/lib.go".to_string()];
let java_files = vec!["src/Main.java".to_string()];
let mixed_files = vec!["cmd/main.go".to_string(), "src/Main.java".to_string()];
let py_files = vec!["tools/check.py".to_string()];
assert_eq!(
session_mode_for_files(LanguageMode::Auto, &go_files),
LanguageMode::Go
);
assert_eq!(
session_mode_for_files(LanguageMode::Auto, &java_files),
LanguageMode::Java
);
assert_eq!(
session_mode_for_files(LanguageMode::Auto, &mixed_files),
LanguageMode::Auto
);
assert_eq!(
session_mode_for_files(LanguageMode::Auto, &py_files),
LanguageMode::Auto
);
}
#[test]
fn session_mode_for_symbols_auto_selects_go_java_servers() {
let go_sym = crate::ir::Symbol {
id: crate::ir::SymbolId::new("go", "main.go", &crate::ir::SymbolKind::Function, "b", 1),
name: "b".to_string(),
kind: crate::ir::SymbolKind::Function,
file: "main.go".to_string(),
range: crate::ir::TextRange {
start_line: 1,
end_line: 1,
},
language: "go".to_string(),
};
let java_sym = crate::ir::Symbol {
id: crate::ir::SymbolId::new(
"java",
"Main.java",
&crate::ir::SymbolKind::Method,
"b",
1,
),
name: "b".to_string(),
kind: crate::ir::SymbolKind::Method,
file: "Main.java".to_string(),
range: crate::ir::TextRange {
start_line: 1,
end_line: 1,
},
language: "java".to_string(),
};
assert_eq!(
session_mode_for_symbols(LanguageMode::Auto, std::slice::from_ref(&go_sym)),
LanguageMode::Go
);
assert_eq!(
session_mode_for_symbols(LanguageMode::Auto, std::slice::from_ref(&java_sym)),
LanguageMode::Java
);
assert_eq!(
session_mode_for_symbols(LanguageMode::Auto, &[go_sym, java_sym]),
LanguageMode::Auto
);
}
#[test]
fn profile_for_mode_includes_python_go_java() {
assert_eq!(
profile_for_mode(LanguageMode::Python),
Some(LangProfile {
symbol_lang: "python",
lsp_language_id: "python"
})
);
assert_eq!(
profile_for_mode(LanguageMode::Go),
Some(LangProfile {
symbol_lang: "go",
lsp_language_id: "go"
})
);
assert_eq!(
profile_for_mode(LanguageMode::Java),
Some(LangProfile {
symbol_lang: "java",
lsp_language_id: "java"
})
);
}
#[test]
fn profile_for_path_detects_languages() {
assert_eq!(
profile_for_path("src/lib.rs"),
Some(LangProfile {
symbol_lang: "rust",
lsp_language_id: "rust"
})
);
assert_eq!(
profile_for_path("src/app.rb"),
Some(LangProfile {
symbol_lang: "ruby",
lsp_language_id: "ruby"
})
);
assert_eq!(
profile_for_path("web/main.ts"),
Some(LangProfile {
symbol_lang: "typescript",
lsp_language_id: "typescript"
})
);
assert_eq!(
profile_for_path("web/main.tsx"),
Some(LangProfile {
symbol_lang: "tsx",
lsp_language_id: "typescriptreact"
})
);
assert_eq!(
profile_for_path("web/main.js"),
Some(LangProfile {
symbol_lang: "javascript",
lsp_language_id: "javascript"
})
);
assert_eq!(
profile_for_path("tools/check.py"),
Some(LangProfile {
symbol_lang: "python",
lsp_language_id: "python"
})
);
assert_eq!(
profile_for_path("cmd/main.go"),
Some(LangProfile {
symbol_lang: "go",
lsp_language_id: "go"
})
);
assert_eq!(
profile_for_path("src/Main.java"),
Some(LangProfile {
symbol_lang: "java",
lsp_language_id: "java"
})
);
assert_eq!(profile_for_path("README.md"), None);
}
#[test]
fn path_matches_mode_respects_lang_filters() {
assert!(path_matches_mode("src/lib.rs", LanguageMode::Rust));
assert!(!path_matches_mode("src/lib.rs", LanguageMode::Ruby));
assert!(path_matches_mode("src/app.tsx", LanguageMode::Tsx));
assert!(!path_matches_mode("src/app.tsx", LanguageMode::Typescript));
assert!(path_matches_mode("src/app.mjs", LanguageMode::Javascript));
assert!(path_matches_mode("src/app.mts", LanguageMode::Typescript));
assert!(path_matches_mode("src/app.rb", LanguageMode::Auto));
assert!(path_matches_mode("src/tool.py", LanguageMode::Auto));
assert!(path_matches_mode("cmd/main.go", LanguageMode::Auto));
assert!(path_matches_mode("src/Main.java", LanguageMode::Auto));
assert!(path_matches_mode("src/tool.py", LanguageMode::Python));
assert!(path_matches_mode("cmd/main.go", LanguageMode::Go));
assert!(path_matches_mode("src/Main.java", LanguageMode::Java));
assert!(!path_matches_mode("src/tool.py", LanguageMode::Rust));
assert!(!path_matches_mode("cmd/main.go", LanguageMode::Java));
assert!(!path_matches_mode("src/Main.java", LanguageMode::Go));
assert!(!path_matches_mode("src/app.md", LanguageMode::Auto));
}
#[test]
fn profile_for_path_or_mode_python_detection_and_override() {
assert_eq!(
profile_for_path_or_mode("pkg/module.py", LanguageMode::Auto),
Some(LangProfile {
symbol_lang: "python",
lsp_language_id: "python"
})
);
assert_eq!(
profile_for_path_or_mode("pkg/module.py", LanguageMode::Python),
Some(LangProfile {
symbol_lang: "python",
lsp_language_id: "python"
})
);
assert_eq!(
profile_for_path_or_mode("pkg/module.py", LanguageMode::Rust),
Some(LangProfile {
symbol_lang: "rust",
lsp_language_id: "rust"
})
);
}
#[test]
fn did_open_language_id_for_path_uses_python_go_java() {
assert_eq!(
did_open_language_id_for_path("pkg/module.py", LanguageMode::Auto),
Some("python")
);
assert_eq!(
did_open_language_id_for_path("cmd/main.go", LanguageMode::Auto),
Some("go")
);
assert_eq!(
did_open_language_id_for_path("src/Main.java", LanguageMode::Auto),
Some("java")
);
assert_eq!(
did_open_language_id_for_path("pkg/module.py", LanguageMode::Rust),
Some("rust")
);
assert_eq!(
did_open_language_id_for_path("any.ext", LanguageMode::Python),
Some("python")
);
assert_eq!(
did_open_language_id_for_path("any.ext", LanguageMode::Go),
Some("go")
);
assert_eq!(
did_open_language_id_for_path("any.ext", LanguageMode::Java),
Some("java")
);
}
#[test]
fn item_to_symbol_handles_call_hierarchy_wrappers() {
let incoming = json!({
"from": {
"name": "foo",
"kind": 12,
"uri": "file:///tmp/src/main.rs",
"selectionRange": {
"start": {"line": 9, "character": 3},
"end": {"line": 9, "character": 6}
}
}
});
let sym = item_to_symbol(&incoming).expect("incoming wrapper should parse");
assert_eq!(sym.name, "foo");
assert_eq!(sym.file, "/tmp/src/main.rs");
assert_eq!(sym.range.start_line, 10);
let outgoing = json!({
"to": {
"name": "bar",
"kind": 12,
"uri": "file:///tmp/src/lib.rs",
"selectionRange": {
"start": {"line": 4, "character": 1},
"end": {"line": 4, "character": 4}
}
}
});
let sym2 = item_to_symbol(&outgoing).expect("outgoing wrapper should parse");
assert_eq!(sym2.name, "bar");
assert_eq!(sym2.file, "/tmp/src/lib.rs");
assert_eq!(sym2.range.start_line, 5);
}
#[test]
fn enclosing_symbol_prefers_most_specific_candidate() {
let items = vec![
json!({
"name": "bar",
"kind": 12,
"range": {
"start": {"line": 0, "character": 0},
"end": {"line": 3, "character": 0}
}
}),
json!({
"name": "foo",
"kind": 12,
"range": {
"start": {"line": 1, "character": 0},
"end": {"line": 3, "character": 0}
}
}),
json!({
"name": "main",
"kind": 12,
"range": {
"start": {"line": 2, "character": 0},
"end": {"line": 3, "character": 0}
}
}),
];
let sym = enclosing_symbol_in_doc(&items, "/tmp/src/main.rs", 2)
.expect("should pick enclosing symbol");
assert_eq!(sym.name, "main");
}
}