use crate::services::async_bridge::AsyncBridge;
use crate::services::lsp::async_handler::LspHandle;
use crate::types::{FeatureFilter, LspFeature, LspServerConfig};
use lsp_types::{SemanticTokensLegend, Uri};
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::time::{Duration, Instant};
fn fire_and_forget<E: std::fmt::Debug>(result: Result<(), E>) {
if let Err(e) = result {
tracing::trace!(error = ?e, "fire-and-forget operation failed");
}
}
#[derive(Debug, Clone)]
pub struct LanguageScope(Vec<String>);
impl LanguageScope {
pub fn all() -> Self {
Self(Vec::new())
}
pub fn single(language: impl Into<String>) -> Self {
Self(vec![language.into()])
}
pub fn accepts(&self, language: &str) -> bool {
self.0.is_empty() || self.0.iter().any(|l| l == language)
}
pub fn is_universal(&self) -> bool {
self.0.is_empty()
}
pub fn languages(&self) -> &[String] {
&self.0
}
pub fn label(&self) -> &str {
self.0.first().map(|s| s.as_str()).unwrap_or("universal")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LspSpawnResult {
Spawned,
NotAutoStart,
NotConfigured,
Disabled,
Failed,
}
const MAX_RESTARTS_IN_WINDOW: usize = 5;
const RESTART_WINDOW_SECS: u64 = 180; const RESTART_BACKOFF_BASE_MS: u64 = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpawnDecision {
Existing,
Allow,
PendingBackoff,
CooledDown,
}
fn path_to_uri(path: &Path) -> Option<Uri> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(path)
};
let encoded: String = abs
.components()
.filter_map(|c| match c {
std::path::Component::RootDir => None, std::path::Component::Normal(s) => {
let s = s.to_str()?;
let mut out = String::with_capacity(s.len() + 1);
out.push('/');
for b in s.bytes() {
if b.is_ascii_alphanumeric()
|| matches!(
b,
b'-' | b'.'
| b'_'
| b'~'
| b'@'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'+'
| b','
| b';'
| b'='
)
{
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
Some(out)
}
_ => None,
})
.collect();
format!("file://{}", encoded).parse().ok()
}
pub fn detect_workspace_root(file_path: &Path, root_markers: &[String]) -> std::path::PathBuf {
let file_dir = file_path.parent().unwrap_or(file_path).to_path_buf();
if root_markers.is_empty() {
return file_dir;
}
let mut dir = Some(file_dir.as_path());
while let Some(d) = dir {
for marker in root_markers {
if d.join(marker).exists() {
return d.to_path_buf();
}
}
dir = d.parent();
}
file_dir
}
#[derive(Debug, Clone, Default)]
pub struct ServerCapabilitySummary {
pub initialized: bool,
pub hover: bool,
pub completion: bool,
pub completion_resolve: bool,
pub completion_trigger_characters: Vec<String>,
pub definition: bool,
pub references: bool,
pub document_formatting: bool,
pub document_range_formatting: bool,
pub rename: bool,
pub signature_help: bool,
pub inlay_hints: bool,
pub folding_ranges: bool,
pub semantic_tokens_full: bool,
pub semantic_tokens_full_delta: bool,
pub semantic_tokens_range: bool,
pub semantic_tokens_legend: Option<SemanticTokensLegend>,
pub document_highlight: bool,
pub code_action: bool,
pub code_action_resolve: bool,
pub document_symbols: bool,
pub workspace_symbols: bool,
pub diagnostics: bool,
}
impl ServerCapabilitySummary {
pub fn apply_dynamic_registration(
&mut self,
method: &str,
register_options: Option<&serde_json::Value>,
register: bool,
) -> bool {
use lsp_types::SemanticTokensFullOptions;
match method {
"textDocument/hover" => self.hover = register,
"textDocument/completion" => {
self.completion = register;
if register {
if let Some(opts) = register_options {
if let Some(chars) =
opts.get("triggerCharacters").and_then(|v| v.as_array())
{
self.completion_trigger_characters = chars
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
}
if let Some(resolve) = opts
.get("resolveProvider")
.and_then(serde_json::Value::as_bool)
{
self.completion_resolve = resolve;
}
}
} else {
self.completion_trigger_characters.clear();
self.completion_resolve = false;
}
}
"textDocument/definition" => self.definition = register,
"textDocument/references" => self.references = register,
"textDocument/formatting" => self.document_formatting = register,
"textDocument/rangeFormatting" => self.document_range_formatting = register,
"textDocument/rename" => self.rename = register,
"textDocument/signatureHelp" => self.signature_help = register,
"textDocument/inlayHint" => self.inlay_hints = register,
"textDocument/foldingRange" => self.folding_ranges = register,
"textDocument/documentHighlight" => self.document_highlight = register,
"textDocument/codeAction" => {
self.code_action = register;
if register {
if let Some(resolve) = register_options
.and_then(|opts| opts.get("resolveProvider"))
.and_then(serde_json::Value::as_bool)
{
self.code_action_resolve = resolve;
}
} else {
self.code_action_resolve = false;
}
}
"textDocument/documentSymbol" => self.document_symbols = register,
"workspace/symbol" => self.workspace_symbols = register,
"textDocument/diagnostic" => self.diagnostics = register,
"textDocument/semanticTokens" => {
if register {
match register_options.and_then(|opts| {
serde_json::from_value::<lsp_types::SemanticTokensOptions>(opts.clone())
.ok()
}) {
Some(opts) => {
self.semantic_tokens_legend = Some(opts.legend);
match opts.full {
Some(SemanticTokensFullOptions::Bool(v)) => {
self.semantic_tokens_full = v;
self.semantic_tokens_full_delta = false;
}
Some(SemanticTokensFullOptions::Delta { delta }) => {
self.semantic_tokens_full = true;
self.semantic_tokens_full_delta = delta.unwrap_or(false);
}
None => {
self.semantic_tokens_full = false;
self.semantic_tokens_full_delta = false;
}
}
self.semantic_tokens_range = opts.range.unwrap_or(false);
}
None => self.semantic_tokens_full = true,
}
} else {
self.semantic_tokens_full = false;
self.semantic_tokens_full_delta = false;
self.semantic_tokens_range = false;
self.semantic_tokens_legend = None;
}
}
_ => return false,
}
true
}
}
pub struct ServerHandle {
pub name: String,
pub handle: LspHandle,
pub feature_filter: FeatureFilter,
pub capabilities: ServerCapabilitySummary,
}
impl ServerHandle {
pub fn has_capability(&self, feature: LspFeature) -> bool {
if !self.capabilities.initialized {
return false;
}
match feature {
LspFeature::Hover => self.capabilities.hover,
LspFeature::Completion => self.capabilities.completion,
LspFeature::Definition => self.capabilities.definition,
LspFeature::References => self.capabilities.references,
LspFeature::Format => {
self.capabilities.document_formatting || self.capabilities.document_range_formatting
}
LspFeature::Rename => self.capabilities.rename,
LspFeature::SignatureHelp => self.capabilities.signature_help,
LspFeature::InlayHints => self.capabilities.inlay_hints,
LspFeature::FoldingRange => self.capabilities.folding_ranges,
LspFeature::SemanticTokens => {
self.capabilities.semantic_tokens_full || self.capabilities.semantic_tokens_range
}
LspFeature::DocumentHighlight => self.capabilities.document_highlight,
LspFeature::CodeAction => self.capabilities.code_action,
LspFeature::DocumentSymbols => self.capabilities.document_symbols,
LspFeature::WorkspaceSymbols => self.capabilities.workspace_symbols,
LspFeature::Diagnostics => self.capabilities.diagnostics,
}
}
}
pub struct LspManager {
window_id: fresh_core::WindowId,
handles: Vec<ServerHandle>,
config: HashMap<String, Vec<LspServerConfig>>,
universal_configs: Vec<LspServerConfig>,
root_uri: Option<Uri>,
per_language_root_uris: HashMap<String, Uri>,
runtime: Option<tokio::runtime::Handle>,
async_bridge: Option<AsyncBridge>,
long_running_spawner: Option<std::sync::Arc<dyn crate::services::remote::LongRunningSpawner>>,
workspace_trust: Option<std::sync::Arc<crate::services::workspace_trust::WorkspaceTrust>>,
path_translation: Option<crate::services::authority::PathTranslation>,
restart_attempts: HashMap<String, Vec<Instant>>,
restart_cooldown: HashSet<String>,
pending_restarts: HashMap<String, Instant>,
allowed_languages: HashSet<String>,
disabled_languages: HashSet<String>,
}
impl LspManager {
pub fn window_id(&self) -> fresh_core::WindowId {
self.window_id
}
pub fn new(window_id: fresh_core::WindowId, root_uri: Option<Uri>) -> Self {
Self {
window_id,
handles: Vec::new(),
config: HashMap::new(),
universal_configs: Vec::new(),
root_uri,
per_language_root_uris: HashMap::new(),
runtime: None,
async_bridge: None,
long_running_spawner: None,
workspace_trust: None,
path_translation: None,
restart_attempts: HashMap::new(),
restart_cooldown: HashSet::new(),
pending_restarts: HashMap::new(),
allowed_languages: HashSet::new(),
disabled_languages: HashSet::new(),
}
}
pub fn set_long_running_spawner(
&mut self,
spawner: std::sync::Arc<dyn crate::services::remote::LongRunningSpawner>,
) {
self.long_running_spawner = Some(spawner);
}
pub fn set_workspace_trust(
&mut self,
trust: std::sync::Arc<crate::services::workspace_trust::WorkspaceTrust>,
) {
self.workspace_trust = Some(trust);
}
fn lsp_autostart_allowed(&self) -> bool {
use crate::services::workspace_trust::TrustLevel;
self.workspace_trust
.as_ref()
.map(|t| t.level() == TrustLevel::Trusted)
.unwrap_or(true)
}
pub fn set_path_translation(
&mut self,
translation: Option<crate::services::authority::PathTranslation>,
) {
self.path_translation = translation;
}
pub fn command_exists_via_authority(&self, command: &str) -> bool {
if command.is_empty() {
return false;
}
let (Some(runtime), Some(spawner)) =
(self.runtime.as_ref(), self.long_running_spawner.as_ref())
else {
return crate::services::lsp::command_exists(command);
};
runtime.block_on(spawner.command_exists(command))
}
pub fn is_language_allowed(&self, language: &str) -> bool {
self.allowed_languages.contains(language)
}
pub fn allow_language(&mut self, language: &str) {
self.allowed_languages.insert(language.to_string());
tracing::info!("LSP language '{}' manually enabled", language);
}
pub fn allowed_languages(&self) -> &HashSet<String> {
&self.allowed_languages
}
pub fn get_configs(&self, language: &str) -> Option<&[LspServerConfig]> {
self.config.get(language).map(|v| v.as_slice())
}
pub fn get_config(&self, language: &str) -> Option<&LspServerConfig> {
self.config.get(language).and_then(|v| v.first())
}
pub fn set_server_capabilities(
&mut self,
_language: &str,
server_name: &str,
mut capabilities: ServerCapabilitySummary,
) {
capabilities.initialized = true;
if let Some(sh) = self.handles.iter_mut().find(|sh| sh.name == server_name) {
sh.capabilities = capabilities;
}
}
pub fn apply_dynamic_capabilities(
&mut self,
server_name: &str,
register: bool,
registrations: &[(String, Option<serde_json::Value>)],
) -> bool {
let Some(sh) = self.handles.iter_mut().find(|sh| sh.name == server_name) else {
return false;
};
let mut changed = false;
for (method, options) in registrations {
if sh
.capabilities
.apply_dynamic_registration(method, options.as_ref(), register)
{
changed = true;
}
}
changed
}
pub fn semantic_tokens_legend(&self, language: &str) -> Option<&SemanticTokensLegend> {
self.get_handles(language).into_iter().find_map(|sh| {
if sh.feature_filter.allows(LspFeature::SemanticTokens)
&& sh.has_capability(LspFeature::SemanticTokens)
{
sh.capabilities.semantic_tokens_legend.as_ref()
} else {
None
}
})
}
pub fn semantic_tokens_full_supported(&self, language: &str) -> bool {
self.get_handles(language).iter().any(|sh| {
sh.feature_filter.allows(LspFeature::SemanticTokens)
&& sh.capabilities.semantic_tokens_full
})
}
pub fn semantic_tokens_full_delta_supported(&self, language: &str) -> bool {
self.get_handles(language).iter().any(|sh| {
sh.feature_filter.allows(LspFeature::SemanticTokens)
&& sh.capabilities.semantic_tokens_full_delta
})
}
pub fn semantic_tokens_range_supported(&self, language: &str) -> bool {
self.get_handles(language).iter().any(|sh| {
sh.feature_filter.allows(LspFeature::SemanticTokens)
&& sh.capabilities.semantic_tokens_range
})
}
pub fn folding_ranges_supported(&self, language: &str) -> bool {
self.get_handles(language).iter().any(|sh| {
sh.feature_filter.allows(LspFeature::FoldingRange) && sh.capabilities.folding_ranges
})
}
pub fn is_completion_trigger_char(&self, ch: char, language: &str) -> bool {
let ch_str = ch.to_string();
self.get_handles(language).iter().any(|sh| {
sh.feature_filter.allows(LspFeature::Completion)
&& sh
.capabilities
.completion_trigger_characters
.contains(&ch_str)
})
}
pub fn try_spawn(&mut self, language: &str, file_path: Option<&Path>) -> LspSpawnResult {
if self
.handles
.iter()
.any(|sh| sh.handle.scope().accepts(language))
{
self.ensure_universal_servers_running(file_path);
return LspSpawnResult::Spawned;
}
if self.runtime.is_none() || self.async_bridge.is_none() {
return LspSpawnResult::Failed;
}
if !self.lsp_autostart_allowed() && !self.allowed_languages.contains(language) {
tracing::info!(
"LSP for '{}' not auto-started: workspace is not trusted \
(trust the folder to enable language servers)",
language
);
return LspSpawnResult::NotAutoStart;
}
self.ensure_universal_servers_running(file_path);
let configs = match self.config.get(language) {
Some(configs) if !configs.is_empty() => configs,
_ => {
if self
.handles
.iter()
.any(|sh| sh.handle.scope().is_universal())
{
return LspSpawnResult::Spawned;
}
return LspSpawnResult::NotConfigured;
}
};
if !configs.iter().any(|c| c.enabled) {
if self
.handles
.iter()
.any(|sh| sh.handle.scope().is_universal())
{
return LspSpawnResult::Spawned;
}
return LspSpawnResult::Disabled;
}
let any_auto_start = configs.iter().any(|c| c.auto_start && c.enabled);
if !any_auto_start && !self.allowed_languages.contains(language) {
if self
.handles
.iter()
.any(|sh| sh.handle.scope().is_universal())
{
return LspSpawnResult::Spawned;
}
return LspSpawnResult::NotAutoStart;
}
let spawned = self.force_spawn(language, file_path).is_some();
if spawned
|| self
.handles
.iter()
.any(|sh| sh.handle.scope().is_universal())
{
LspSpawnResult::Spawned
} else {
LspSpawnResult::Failed
}
}
pub fn set_runtime(&mut self, runtime: tokio::runtime::Handle, async_bridge: AsyncBridge) {
self.runtime = Some(runtime);
self.async_bridge = Some(async_bridge);
}
pub fn set_language_config(&mut self, language: String, config: LspServerConfig) {
self.config.insert(language, vec![config]);
}
pub fn set_language_configs(&mut self, language: String, configs: Vec<LspServerConfig>) {
self.config.insert(language, configs);
}
pub fn append_language_configs(&mut self, language: String, configs: Vec<LspServerConfig>) {
self.config.entry(language).or_default().extend(configs);
}
pub fn set_universal_configs(&mut self, configs: Vec<LspServerConfig>) {
self.universal_configs = configs;
}
pub fn configured_languages(&self) -> Vec<String> {
self.config.keys().cloned().collect()
}
pub fn set_root_uri(&mut self, root_uri: Option<Uri>) {
self.root_uri = root_uri;
}
pub fn set_language_root_uri(&mut self, language: &str, uri: Uri) -> bool {
tracing::info!("Setting root URI for {}: {}", language, uri.as_str());
self.per_language_root_uris
.insert(language.to_string(), uri.clone());
if self
.handles
.iter()
.any(|sh| sh.handle.scope().accepts(language))
{
tracing::info!(
"Restarting {} LSP server with new root: {}",
language,
uri.as_str()
);
self.shutdown_server(language);
return true;
}
false
}
pub fn resolve_root_uri(&self, language: &str, file_path: Option<&Path>) -> Option<Uri> {
if let Some(uri) = self.per_language_root_uris.get(language) {
return Some(uri.clone());
}
if let Some(path) = file_path {
let markers = self
.config
.get(language)
.and_then(|configs| configs.first())
.map(|c| c.root_markers.as_slice())
.unwrap_or(&[]);
let root = detect_workspace_root(path, markers);
let mapped = self
.path_translation
.as_ref()
.and_then(|t| t.host_to_remote(&root))
.unwrap_or(root);
if let Some(uri) = path_to_uri(&mapped) {
return Some(uri);
}
}
self.root_uri.clone()
}
pub fn get_effective_root_uri(&self, language: &str) -> Option<Uri> {
self.resolve_root_uri(language, None)
}
pub fn reset_for_new_project(&mut self, new_root_uri: Option<Uri>) {
self.shutdown_all();
self.root_uri = new_root_uri;
self.restart_attempts.clear();
self.restart_cooldown.clear();
self.pending_restarts.clear();
tracing::info!(
"LSP manager reset for new project: {:?}",
self.root_uri.as_ref().map(|u| u.as_str())
);
}
pub fn get_handle(&self, language: &str) -> Option<&LspHandle> {
self.handles
.iter()
.find(|sh| sh.handle.scope().accepts(language))
.map(|sh| &sh.handle)
}
pub fn get_handle_mut(&mut self, language: &str) -> Option<&mut LspHandle> {
self.handles
.iter_mut()
.find(|sh| sh.handle.scope().accepts(language))
.map(|sh| &mut sh.handle)
}
pub fn get_handles(&self, language: &str) -> Vec<&ServerHandle> {
self.handles
.iter()
.filter(|sh| sh.handle.scope().accepts(language))
.collect()
}
pub fn get_handles_mut(&mut self, language: &str) -> Vec<&mut ServerHandle> {
self.handles
.iter_mut()
.filter(|sh| sh.handle.scope().accepts(language))
.collect()
}
pub fn server_scope(&self, server_name: &str) -> Option<&LanguageScope> {
self.handles
.iter()
.find(|sh| sh.name == server_name)
.map(|sh| sh.handle.scope())
}
pub fn has_handles(&self, language: &str) -> bool {
self.handles
.iter()
.any(|sh| sh.handle.scope().accepts(language))
}
pub fn handle_count(&self, language: &str) -> usize {
self.handles
.iter()
.filter(|sh| sh.handle.scope().accepts(language))
.count()
}
pub fn has_server_named(&self, server_name: &str) -> bool {
self.handles.iter().any(|sh| sh.name == server_name)
}
pub fn handle_for_feature(&self, language: &str, feature: LspFeature) -> Option<&ServerHandle> {
self.handles
.iter()
.filter(|sh| sh.handle.scope().accepts(language))
.find(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
}
pub fn handle_for_feature_mut(
&mut self,
language: &str,
feature: LspFeature,
) -> Option<&mut ServerHandle> {
self.handles
.iter_mut()
.filter(|sh| sh.handle.scope().accepts(language))
.find(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
}
pub fn handles_for_feature(&self, language: &str, feature: LspFeature) -> Vec<&ServerHandle> {
self.handles
.iter()
.filter(|sh| sh.handle.scope().accepts(language))
.filter(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
.collect()
}
pub fn handles_for_feature_mut(
&mut self,
language: &str,
feature: LspFeature,
) -> Vec<&mut ServerHandle> {
self.handles
.iter_mut()
.filter(|sh| sh.handle.scope().accepts(language))
.filter(|sh| sh.feature_filter.allows(feature) && sh.has_capability(feature))
.collect()
}
fn spawn_decision(&mut self, language: &str) -> SpawnDecision {
if self
.handles
.iter()
.any(|sh| sh.handle.scope().accepts(language))
{
return SpawnDecision::Existing;
}
if self.restart_cooldown.contains(language) {
return SpawnDecision::CooledDown;
}
if self.pending_restarts.contains_key(language) {
return SpawnDecision::PendingBackoff;
}
let now = Instant::now();
let window = Duration::from_secs(RESTART_WINDOW_SECS);
let attempts = self
.restart_attempts
.entry(language.to_string())
.or_default();
attempts.retain(|t| now.duration_since(*t) < window);
if attempts.len() >= MAX_RESTARTS_IN_WINDOW {
self.restart_cooldown.insert(language.to_string());
tracing::warn!(
"LSP server for {} has spawned {} times in {} minutes, entering cooldown",
language,
MAX_RESTARTS_IN_WINDOW,
RESTART_WINDOW_SECS / 60
);
return SpawnDecision::CooledDown;
}
attempts.push(now);
SpawnDecision::Allow
}
pub fn force_spawn(
&mut self,
language: &str,
file_path: Option<&Path>,
) -> Option<&mut LspHandle> {
tracing::debug!("force_spawn called for language: {}", language);
if self
.handles
.iter()
.any(|sh| sh.handle.scope().accepts(language))
{
tracing::debug!("force_spawn: returning existing handle for {}", language);
return self
.handles
.iter_mut()
.find(|sh| sh.handle.scope().accepts(language))
.map(|sh| &mut sh.handle);
}
if self.disabled_languages.contains(language) {
tracing::debug!(
"LSP for {} is disabled, not spawning (use manual restart to re-enable)",
language
);
return None;
}
let configs = match self.config.get(language) {
Some(configs) if !configs.is_empty() => configs.clone(),
_ => {
tracing::warn!(
"force_spawn: no config found for language '{}', available configs: {:?}",
language,
self.config.keys().collect::<Vec<_>>()
);
return None;
}
};
match self.spawn_decision(language) {
SpawnDecision::Existing => {
return self
.handles
.iter_mut()
.find(|sh| sh.handle.scope().accepts(language))
.map(|sh| &mut sh.handle);
}
SpawnDecision::CooledDown => {
tracing::debug!(
"force_spawn: {} is in cooldown, refusing spawn (use Restart LSP command)",
language
);
return None;
}
SpawnDecision::PendingBackoff => {
tracing::debug!(
"force_spawn: {} has a pending restart scheduled, not double-spawning",
language
);
return None;
}
SpawnDecision::Allow => {}
}
let runtime = match self.runtime.as_ref() {
Some(r) => r.clone(),
None => {
tracing::error!("force_spawn: no tokio runtime available for {}", language);
return None;
}
};
let async_bridge = match self.async_bridge.as_ref() {
Some(b) => b.clone(),
None => {
tracing::error!("force_spawn: no async bridge available for {}", language);
return None;
}
};
let long_running_spawner = match self.long_running_spawner.as_ref() {
Some(s) => s.clone(),
None => {
tracing::warn!(
"force_spawn: long-running spawner not wired for {} — \
falling back to host-local spawn (normal for tests \
that skip set_boot_authority)",
language
);
std::sync::Arc::new(crate::services::remote::LocalLongRunningSpawner::new(
std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive()),
std::sync::Arc::new(
crate::services::workspace_trust::WorkspaceTrust::permissive(),
),
))
}
};
let mut spawned_handles = Vec::new();
let manually_allowed = self.allowed_languages.contains(language);
for config in &configs {
if manually_allowed {
} else {
if !config.enabled || !config.auto_start {
continue;
}
}
if config.command.is_empty() {
tracing::warn!(
"force_spawn: LSP command is empty for {} server '{}'",
language,
config.display_name()
);
continue;
}
let server_name = config.display_name();
tracing::info!(
"Spawning LSP server '{}' for language: {}",
server_name,
language
);
match LspHandle::spawn(
&runtime,
&config.command,
&config.args,
config.env.clone(),
LanguageScope::single(language),
server_name.clone(),
&async_bridge,
config.process_limits.clone(),
config.language_id_overrides.clone(),
long_running_spawner.clone(),
) {
Ok(handle) => {
let effective_root = self.resolve_root_uri(language, file_path);
if let Err(e) =
handle.initialize(effective_root, config.initialization_options.clone())
{
tracing::error!(
"Failed to send initialize command for {} ({}): {}",
language,
server_name,
e
);
continue;
}
tracing::info!(
"LSP initialization started for {} ({}), will be ready asynchronously",
language,
server_name
);
spawned_handles.push(ServerHandle {
name: server_name,
handle,
feature_filter: config.feature_filter(),
capabilities: ServerCapabilitySummary::default(),
});
}
Err(e) => {
tracing::error!(
"Failed to spawn LSP handle for {} ({}): {}",
language,
server_name,
e
);
}
}
}
if spawned_handles.is_empty() {
return None;
}
self.handles.extend(spawned_handles);
self.handles
.iter_mut()
.rev()
.find(|sh| sh.handle.scope().accepts(language))
.map(|sh| &mut sh.handle)
}
fn ensure_universal_servers_running(&mut self, file_path: Option<&Path>) {
if self
.handles
.iter()
.any(|sh| sh.handle.scope().is_universal())
|| self.universal_configs.is_empty()
{
return;
}
let runtime = match self.runtime.as_ref() {
Some(r) => r.clone(),
None => return,
};
let async_bridge = match self.async_bridge.as_ref() {
Some(b) => b.clone(),
None => return,
};
let long_running_spawner =
self.long_running_spawner
.as_ref()
.cloned()
.unwrap_or_else(|| {
std::sync::Arc::new(crate::services::remote::LocalLongRunningSpawner::new(
std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive()),
std::sync::Arc::new(
crate::services::workspace_trust::WorkspaceTrust::permissive(),
),
))
});
let mut spawned = Vec::new();
for config in &self.universal_configs {
if !config.enabled || !config.auto_start {
continue;
}
if config.command.is_empty() {
continue;
}
let server_name = config.display_name();
tracing::info!("Spawning universal LSP server '{}'", server_name);
match LspHandle::spawn(
&runtime,
&config.command,
&config.args,
config.env.clone(),
LanguageScope::all(),
server_name.clone(),
&async_bridge,
config.process_limits.clone(),
config.language_id_overrides.clone(),
long_running_spawner.clone(),
) {
Ok(handle) => {
let effective_root = file_path
.and_then(|p| {
let root = detect_workspace_root(p, &config.root_markers);
path_to_uri(&root)
})
.or_else(|| self.root_uri.clone());
if let Err(e) =
handle.initialize(effective_root, config.initialization_options.clone())
{
tracing::error!(
"Failed to initialize universal LSP server '{}': {}",
server_name,
e
);
continue;
}
tracing::info!(
"Universal LSP server '{}' initialization started",
server_name
);
spawned.push(ServerHandle {
name: server_name,
handle,
feature_filter: config.feature_filter(),
capabilities: ServerCapabilitySummary::default(),
});
}
Err(e) => {
tracing::error!(
"Failed to spawn universal LSP server '{}': {}",
server_name,
e
);
}
}
}
self.handles.extend(spawned);
}
pub fn handle_server_crash(&mut self, language: &str, server_name: &str) -> String {
if self
.handles
.iter()
.any(|sh| sh.name == server_name && sh.handle.scope().is_universal())
{
let universals: Vec<ServerHandle> = {
let mut drained = Vec::new();
let mut i = 0;
while i < self.handles.len() {
if self.handles[i].handle.scope().is_universal() {
drained.push(self.handles.remove(i));
} else {
i += 1;
}
}
drained
};
for sh in universals {
fire_and_forget(sh.handle.shutdown());
}
return "Universal LSP server crashed. It will restart on next file open.".to_string();
}
{
let mut i = 0;
while i < self.handles.len() {
if !self.handles[i].handle.scope().is_universal()
&& self.handles[i].handle.scope().accepts(language)
{
let sh = self.handles.remove(i);
fire_and_forget(sh.handle.shutdown());
} else {
i += 1;
}
}
}
if self.disabled_languages.contains(language) {
return format!(
"LSP server for {} stopped. Use 'Restart LSP Server' command to start it again.",
language
);
}
if self.restart_cooldown.contains(language) {
return format!(
"LSP server for {} crashed. Too many restarts - use 'Restart LSP Server' command to retry.",
language
);
}
let now = Instant::now();
let attempt_number = self
.restart_attempts
.get(language)
.map(|v| v.len())
.unwrap_or(0);
let delay_ms = RESTART_BACKOFF_BASE_MS * (1 << attempt_number); let restart_time = now + Duration::from_millis(delay_ms);
self.pending_restarts
.insert(language.to_string(), restart_time);
tracing::info!(
"LSP server for {} crashed (attempt {}/{}), will restart in {}ms",
language,
attempt_number + 1,
MAX_RESTARTS_IN_WINDOW,
delay_ms
);
format!(
"LSP server for {} crashed (attempt {}/{}), restarting in {}s...",
language,
attempt_number + 1,
MAX_RESTARTS_IN_WINDOW,
delay_ms / 1000
)
}
pub fn process_pending_restarts(&mut self) -> Vec<(String, bool, String)> {
let now = Instant::now();
let mut results = Vec::new();
let due_restarts: Vec<String> = self
.pending_restarts
.iter()
.filter(|(_, time)| **time <= now)
.map(|(lang, _)| lang.clone())
.collect();
for language in due_restarts {
self.pending_restarts.remove(&language);
if self.force_spawn(&language, None).is_some() {
let message = format!("LSP server for {} restarted successfully", language);
tracing::info!("{}", message);
results.push((language, true, message));
} else {
let message = format!("Failed to restart LSP server for {}", language);
tracing::error!("{}", message);
results.push((language, false, message));
}
}
results
}
pub fn is_in_cooldown(&self, language: &str) -> bool {
self.restart_cooldown.contains(language)
}
pub fn has_pending_restart(&self, language: &str) -> bool {
self.pending_restarts.contains_key(language)
}
pub fn clear_cooldown(&mut self, language: &str) {
self.restart_cooldown.remove(language);
self.restart_attempts.remove(language);
self.pending_restarts.remove(language);
tracing::info!("Cleared restart cooldown for {}", language);
}
pub fn manual_restart(&mut self, language: &str, file_path: Option<&Path>) -> (bool, String) {
self.clear_cooldown(language);
self.disabled_languages.remove(language);
self.allowed_languages.insert(language.to_string());
{
let mut i = 0;
while i < self.handles.len() {
if !self.handles[i].handle.scope().is_universal()
&& self.handles[i].handle.scope().accepts(language)
{
let sh = self.handles.remove(i);
fire_and_forget(sh.handle.shutdown());
} else {
i += 1;
}
}
}
if self.force_spawn(language, file_path).is_some() {
let message = format!("LSP server for {} started", language);
tracing::info!("{}", message);
(true, message)
} else {
let message = format!("Failed to start LSP server for {}", language);
tracing::error!("{}", message);
(false, message)
}
}
pub fn manual_restart_server(
&mut self,
language: &str,
server_name: &str,
file_path: Option<&Path>,
) -> (bool, String) {
self.clear_cooldown(language);
self.disabled_languages.remove(language);
self.allowed_languages.insert(language.to_string());
if let Some(idx) = self.handles.iter().position(|sh| sh.name == server_name) {
let sh = self.handles.remove(idx);
fire_and_forget(sh.handle.shutdown());
}
let is_universal = self
.universal_configs
.iter()
.any(|c| c.display_name() == server_name);
let config = if is_universal {
self.universal_configs
.iter()
.find(|c| c.display_name() == server_name)
.cloned()
} else {
self.config
.get(language)
.and_then(|configs| configs.iter().find(|c| c.display_name() == server_name))
.cloned()
};
let Some(config) = config else {
let message = format!(
"No config found for server '{}' ({})",
server_name, language
);
tracing::error!("{}", message);
return (false, message);
};
if config.command.is_empty() {
let message = format!(
"LSP command is empty for {} server '{}'",
language, server_name
);
tracing::error!("{}", message);
return (false, message);
}
let runtime = match self.runtime.as_ref() {
Some(r) => r.clone(),
None => return (false, "No tokio runtime available".to_string()),
};
let async_bridge = match self.async_bridge.as_ref() {
Some(b) => b.clone(),
None => return (false, "No async bridge available".to_string()),
};
let long_running_spawner =
self.long_running_spawner
.as_ref()
.cloned()
.unwrap_or_else(|| {
std::sync::Arc::new(crate::services::remote::LocalLongRunningSpawner::new(
std::sync::Arc::new(crate::services::env_provider::EnvProvider::inactive()),
std::sync::Arc::new(
crate::services::workspace_trust::WorkspaceTrust::permissive(),
),
))
});
let scope = if is_universal {
LanguageScope::all()
} else {
LanguageScope::single(language)
};
match LspHandle::spawn(
&runtime,
&config.command,
&config.args,
config.env.clone(),
scope,
server_name.to_string(),
&async_bridge,
config.process_limits.clone(),
config.language_id_overrides.clone(),
long_running_spawner,
) {
Ok(handle) => {
let effective_root = if is_universal {
file_path
.and_then(|p| {
let root = detect_workspace_root(p, &config.root_markers);
path_to_uri(&root)
})
.or_else(|| self.root_uri.clone())
} else {
self.resolve_root_uri(language, file_path)
};
if let Err(e) =
handle.initialize(effective_root, config.initialization_options.clone())
{
let message = format!(
"Failed to initialize LSP server '{}' for {}: {}",
server_name, language, e
);
tracing::error!("{}", message);
return (false, message);
}
let sh = ServerHandle {
name: server_name.to_string(),
handle,
feature_filter: config.feature_filter(),
capabilities: ServerCapabilitySummary::default(),
};
self.handles.push(sh);
let message = format!("LSP server '{}' for {} started", server_name, language);
tracing::info!("{}", message);
(true, message)
}
Err(e) => {
let message = format!(
"Failed to start LSP server '{}' for {}: {}",
server_name, language, e
);
tracing::error!("{}", message);
(false, message)
}
}
}
pub fn restart_attempt_count(&self, language: &str) -> usize {
let now = Instant::now();
let window = Duration::from_secs(RESTART_WINDOW_SECS);
self.restart_attempts
.get(language)
.map(|attempts| {
attempts
.iter()
.filter(|t| now.duration_since(**t) < window)
.count()
})
.unwrap_or(0)
}
pub fn running_servers(&self) -> Vec<String> {
let mut labels: Vec<String> = self
.handles
.iter()
.map(|sh| sh.handle.scope().label().to_string())
.collect();
labels.sort();
labels.dedup();
labels
}
pub fn server_names_for_language(&self, language: &str) -> Vec<String> {
self.handles
.iter()
.filter(|sh| sh.handle.scope().accepts(language))
.map(|sh| sh.name.clone())
.collect()
}
pub fn is_server_ready(&self, language: &str) -> bool {
self.handles
.iter()
.filter(|sh| sh.handle.scope().accepts(language))
.any(|sh| sh.handle.state().can_send_requests())
}
pub fn shutdown_server_by_name(&mut self, language: &str, server_name: &str) -> bool {
let Some(idx) = self.handles.iter().position(|sh| sh.name == server_name) else {
tracing::warn!(
"No running LSP server named '{}' found for {}",
server_name,
language
);
return false;
};
let sh = self.handles.remove(idx);
tracing::info!(
"Shutting down LSP server '{}' for {} (disabled until manual restart)",
sh.name,
language
);
fire_and_forget(sh.handle.shutdown());
let has_remaining = self
.handles
.iter()
.any(|sh| !sh.handle.scope().is_universal() && sh.handle.scope().accepts(language));
if !has_remaining {
self.disabled_languages.insert(language.to_string());
self.pending_restarts.remove(language);
self.restart_cooldown.remove(language);
self.allowed_languages.remove(language);
}
true
}
pub fn shutdown_server(&mut self, language: &str) -> bool {
let mut found = false;
let mut i = 0;
while i < self.handles.len() {
if !self.handles[i].handle.scope().is_universal()
&& self.handles[i].handle.scope().accepts(language)
{
let sh = self.handles.remove(i);
tracing::info!(
"Shutting down LSP server '{}' for {} (disabled until manual restart)",
sh.name,
language
);
fire_and_forget(sh.handle.shutdown());
found = true;
} else {
i += 1;
}
}
if found {
self.disabled_languages.insert(language.to_string());
self.pending_restarts.remove(language);
self.restart_cooldown.remove(language);
self.allowed_languages.remove(language);
} else {
tracing::warn!("No running LSP server found for {}", language);
}
found
}
pub fn shutdown_all(&mut self) {
for sh in &self.handles {
tracing::info!(
"Shutting down LSP server '{}' ({})",
sh.name,
sh.handle.scope().label()
);
fire_and_forget(sh.handle.shutdown());
}
self.handles.clear();
}
}
impl Drop for LspManager {
fn drop(&mut self) {
self.shutdown_all();
}
}
pub fn detect_language(
path: &std::path::Path,
languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
) -> Option<String> {
let detected = detect_language_by_config(path, languages);
if detected.as_deref() == Some("c")
&& path.extension().and_then(|e| e.to_str()) == Some("h")
&& languages.contains_key("cpp")
&& header_in_cpp_tree(path)
{
return Some("cpp".to_string());
}
detected
}
fn detect_language_by_config(
path: &std::path::Path,
languages: &std::collections::HashMap<String, crate::config::LanguageConfig>,
) -> Option<String> {
use crate::primitives::glob_match::{
filename_glob_matches, is_glob_pattern, is_path_pattern, path_glob_matches,
};
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
for (language_name, lang_config) in languages {
if lang_config
.filenames
.iter()
.any(|f| !is_glob_pattern(f) && f == filename)
{
return Some(language_name.clone());
}
}
let path_str = path.to_str().unwrap_or("");
for (language_name, lang_config) in languages {
if lang_config.filenames.iter().any(|f| {
if !is_glob_pattern(f) {
return false;
}
if is_path_pattern(f) {
path_glob_matches(f, path_str)
} else {
filename_glob_matches(f, filename)
}
}) {
return Some(language_name.clone());
}
}
}
if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
for (language_name, lang_config) in languages {
if lang_config.extensions.iter().any(|ext| ext == extension) {
return Some(language_name.clone());
}
}
}
None
}
fn header_in_cpp_tree(path: &std::path::Path) -> bool {
let Some(start_dir) = path.parent() else {
return false;
};
if let Ok(entries) = std::fs::read_dir(start_dir) {
for entry in entries.flatten() {
let p = entry.path();
let Some(ext) = p.extension().and_then(|e| e.to_str()) else {
continue;
};
if matches!(
ext,
"cc" | "cpp" | "cxx" | "C" | "c++" | "hpp" | "hh" | "hxx"
) {
return true;
}
}
}
let mut current = Some(start_dir);
let mut depth = 0u32;
while let Some(dir) = current {
let cc = dir.join("compile_commands.json");
if cc.is_file() && compile_commands_has_cpp_marker(&cc) {
return true;
}
if depth >= 10 {
break;
}
depth += 1;
current = dir.parent();
}
false
}
fn compile_commands_has_cpp_marker(path: &std::path::Path) -> bool {
use std::io::Read;
const MAX_READ: u64 = 1_048_576;
let Ok(file) = std::fs::File::open(path) else {
return false;
};
let mut buf = Vec::with_capacity(64 * 1024);
if file.take(MAX_READ).read_to_end(&mut buf).is_err() {
return false;
}
let Ok(text) = std::str::from_utf8(&buf) else {
return false;
};
if text.contains("c++") {
return true;
}
text.contains(".cpp") || text.contains(".cxx") || text.contains(".cc\"")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_lsp_manager_new() {
let root_uri: Option<Uri> = "file:///test".parse().ok();
let manager = LspManager::new(fresh_core::WindowId(1), root_uri.clone());
assert_eq!(manager.handles.len(), 0);
assert_eq!(manager.config.len(), 0);
assert!(manager.root_uri.is_some());
assert!(manager.runtime.is_none());
assert!(manager.async_bridge.is_none());
}
#[test]
fn test_lsp_manager_set_language_config() {
let mut manager = LspManager::new(fresh_core::WindowId(1), None);
let config = LspServerConfig {
enabled: true,
command: "rust-analyzer".to_string(),
args: vec![],
process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
auto_start: false,
initialization_options: None,
env: Default::default(),
language_id_overrides: Default::default(),
name: None,
only_features: None,
except_features: None,
root_markers: Default::default(),
};
manager.set_language_config("rust".to_string(), config);
assert_eq!(manager.config.len(), 1);
assert!(manager.config.contains_key("rust"));
assert!(manager.config.get("rust").unwrap().first().unwrap().enabled);
}
#[test]
fn test_lsp_manager_force_spawn_no_runtime() {
let mut manager = LspManager::new(fresh_core::WindowId(1), None);
manager.set_language_config(
"rust".to_string(),
LspServerConfig {
enabled: true,
command: "rust-analyzer".to_string(),
args: vec![],
process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
auto_start: false,
initialization_options: None,
env: Default::default(),
language_id_overrides: Default::default(),
name: None,
only_features: None,
except_features: None,
root_markers: Default::default(),
},
);
let result = manager.force_spawn("rust", None);
assert!(result.is_none());
}
#[test]
fn test_lsp_manager_force_spawn_no_config() {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut manager = LspManager::new(fresh_core::WindowId(1), None);
let async_bridge = AsyncBridge::new();
manager.set_runtime(rt.handle().clone(), async_bridge);
let result = manager.force_spawn("rust", None);
assert!(result.is_none());
}
#[test]
fn test_lsp_manager_force_spawn_disabled_language() {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut manager = LspManager::new(fresh_core::WindowId(1), None);
let async_bridge = AsyncBridge::new();
manager.set_runtime(rt.handle().clone(), async_bridge);
manager.set_language_config(
"rust".to_string(),
LspServerConfig {
enabled: false,
command: String::new(), args: vec![],
process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
auto_start: false,
initialization_options: None,
env: Default::default(),
language_id_overrides: Default::default(),
name: None,
only_features: None,
except_features: None,
root_markers: Default::default(),
},
);
let result = manager.force_spawn("rust", None);
assert!(result.is_none());
}
#[test]
fn test_lsp_manager_try_spawn_returns_disabled_when_all_configs_disabled() {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut manager = LspManager::new(fresh_core::WindowId(1), None);
let async_bridge = AsyncBridge::new();
manager.set_runtime(rt.handle().clone(), async_bridge);
manager.set_language_config(
"rust".to_string(),
LspServerConfig {
enabled: false,
command: String::new(),
args: vec![],
process_limits: crate::services::process_limits::ProcessLimits::unlimited(),
auto_start: false,
initialization_options: None,
env: Default::default(),
language_id_overrides: Default::default(),
name: None,
only_features: None,
except_features: None,
root_markers: Default::default(),
},
);
assert_eq!(manager.try_spawn("rust", None), LspSpawnResult::Disabled);
}
#[test]
fn test_lsp_manager_shutdown_all() {
let mut manager = LspManager::new(fresh_core::WindowId(1), None);
manager.shutdown_all();
assert_eq!(manager.handles.len(), 0);
}
fn test_languages() -> std::collections::HashMap<String, crate::config::LanguageConfig> {
let mut languages = std::collections::HashMap::new();
languages.insert(
"rust".to_string(),
crate::config::LanguageConfig {
extensions: vec!["rs".to_string()],
filenames: vec![],
grammar: "rust".to_string(),
comment_prefix: Some("//".to_string()),
auto_indent: true,
auto_close: None,
auto_surround: None,
textmate_grammar: None,
show_whitespace_tabs: false,
line_wrap: None,
wrap_column: None,
page_view: None,
page_width: None,
use_tabs: None,
tab_size: None,
formatter: None,
format_on_save: false,
on_save: vec![],
word_characters: None,
},
);
languages.insert(
"javascript".to_string(),
crate::config::LanguageConfig {
extensions: vec!["js".to_string(), "jsx".to_string()],
filenames: vec![],
grammar: "javascript".to_string(),
comment_prefix: Some("//".to_string()),
auto_indent: true,
auto_close: None,
auto_surround: None,
textmate_grammar: None,
show_whitespace_tabs: false,
line_wrap: None,
wrap_column: None,
page_view: None,
page_width: None,
use_tabs: None,
tab_size: None,
formatter: None,
format_on_save: false,
on_save: vec![],
word_characters: None,
},
);
languages.insert(
"csharp".to_string(),
crate::config::LanguageConfig {
extensions: vec!["cs".to_string()],
filenames: vec![],
grammar: "c_sharp".to_string(),
comment_prefix: Some("//".to_string()),
auto_indent: true,
auto_close: None,
auto_surround: None,
textmate_grammar: None,
show_whitespace_tabs: false,
line_wrap: None,
wrap_column: None,
page_view: None,
page_width: None,
use_tabs: None,
tab_size: None,
formatter: None,
format_on_save: false,
on_save: vec![],
word_characters: None,
},
);
languages
}
#[test]
fn test_detect_language_from_config() {
let languages = test_languages();
assert_eq!(
detect_language(Path::new("main.rs"), &languages),
Some("rust".to_string())
);
assert_eq!(
detect_language(Path::new("index.js"), &languages),
Some("javascript".to_string())
);
assert_eq!(
detect_language(Path::new("App.jsx"), &languages),
Some("javascript".to_string())
);
assert_eq!(
detect_language(Path::new("Program.cs"), &languages),
Some("csharp".to_string())
);
assert_eq!(detect_language(Path::new("main.py"), &languages), None);
assert_eq!(detect_language(Path::new("file.xyz"), &languages), None);
assert_eq!(detect_language(Path::new("file"), &languages), None);
}
#[test]
fn test_detect_language_no_extension() {
let languages = test_languages();
assert_eq!(detect_language(Path::new("README"), &languages), None);
assert_eq!(detect_language(Path::new("Makefile"), &languages), None);
}
#[test]
fn test_detect_language_path_glob() {
let mut languages = test_languages();
languages.insert(
"shell".to_string(),
crate::config::LanguageConfig {
extensions: vec!["sh".to_string()],
filenames: vec!["/etc/**/rc.*".to_string(), "*rc".to_string()],
grammar: "bash".to_string(),
comment_prefix: Some("#".to_string()),
auto_indent: true,
auto_close: None,
auto_surround: None,
textmate_grammar: None,
show_whitespace_tabs: false,
line_wrap: None,
wrap_column: None,
page_view: None,
page_width: None,
use_tabs: None,
tab_size: None,
formatter: None,
format_on_save: false,
on_save: vec![],
word_characters: None,
},
);
assert_eq!(
detect_language(Path::new("/etc/rc.conf"), &languages),
Some("shell".to_string())
);
assert_eq!(
detect_language(Path::new("/etc/init/rc.local"), &languages),
Some("shell".to_string())
);
assert_eq!(detect_language(Path::new("/var/rc.conf"), &languages), None);
assert_eq!(
detect_language(Path::new("lfrc"), &languages),
Some("shell".to_string())
);
}
#[test]
fn test_detect_workspace_root_finds_marker_in_parent() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
let src = project.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(project.join("Cargo.toml"), "").unwrap();
let file = src.join("main.rs");
std::fs::write(&file, "").unwrap();
let root = detect_workspace_root(&file, &["Cargo.toml".to_string(), ".git".to_string()]);
assert_eq!(root, project);
}
#[test]
fn test_detect_workspace_root_finds_marker_two_levels_up() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
let deep = project.join("src").join("nested");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(project.join("Cargo.toml"), "").unwrap();
let file = deep.join("lib.rs");
std::fs::write(&file, "").unwrap();
let root = detect_workspace_root(&file, &["Cargo.toml".to_string()]);
assert_eq!(root, project);
}
#[test]
fn test_detect_workspace_root_no_marker_returns_parent() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("somedir");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("file.txt");
std::fs::write(&file, "").unwrap();
let root = detect_workspace_root(&file, &["nonexistent_marker".to_string()]);
assert_eq!(root, dir);
}
#[test]
fn test_detect_workspace_root_empty_markers_returns_parent() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("somedir");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("file.txt");
std::fs::write(&file, "").unwrap();
let root = detect_workspace_root(&file, &[]);
assert_eq!(root, dir);
}
#[test]
fn test_detect_workspace_root_directory_marker() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("myproject");
let src = project.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(project.join(".git")).unwrap();
let file = src.join("main.rs");
std::fs::write(&file, "").unwrap();
let root = detect_workspace_root(&file, &[".git".to_string()]);
assert_eq!(root, project);
}
fn c_cpp_languages() -> std::collections::HashMap<String, crate::config::LanguageConfig> {
use crate::config::LanguageConfig;
let mut languages = std::collections::HashMap::new();
let base = LanguageConfig {
extensions: vec![],
filenames: vec![],
grammar: String::new(),
comment_prefix: Some("//".to_string()),
auto_indent: true,
auto_close: None,
auto_surround: None,
textmate_grammar: None,
show_whitespace_tabs: false,
line_wrap: None,
wrap_column: None,
page_view: None,
page_width: None,
use_tabs: None,
tab_size: None,
formatter: None,
format_on_save: false,
on_save: vec![],
word_characters: None,
};
languages.insert(
"c".to_string(),
LanguageConfig {
extensions: vec!["c".to_string(), "h".to_string()],
grammar: "c".to_string(),
..base.clone()
},
);
languages.insert(
"cpp".to_string(),
LanguageConfig {
extensions: vec![
"cpp".to_string(),
"cc".to_string(),
"cxx".to_string(),
"hpp".to_string(),
"hh".to_string(),
"hxx".to_string(),
],
grammar: "cpp".to_string(),
..base
},
);
languages
}
#[test]
fn test_detect_language_h_stays_c_without_cpp_signals() {
let languages = c_cpp_languages();
assert_eq!(
detect_language(Path::new("foo.h"), &languages),
Some("c".to_string())
);
}
#[test]
fn test_detect_language_h_promotes_to_cpp_with_sibling_cpp_source() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let header = project.join("widget.h");
std::fs::write(&header, "").unwrap();
std::fs::write(project.join("widget.cpp"), "").unwrap();
let languages = c_cpp_languages();
assert_eq!(
detect_language(&header, &languages),
Some("cpp".to_string())
);
}
#[test]
fn test_detect_language_h_promotes_to_cpp_with_sibling_hpp() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let header = project.join("a.h");
std::fs::write(&header, "").unwrap();
std::fs::write(project.join("b.hpp"), "").unwrap();
let languages = c_cpp_languages();
assert_eq!(
detect_language(&header, &languages),
Some("cpp".to_string())
);
}
#[test]
fn test_detect_language_h_promotes_to_cpp_with_ancestor_compile_commands() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("proj");
let include = project.join("include").join("fmt");
std::fs::create_dir_all(&include).unwrap();
std::fs::write(
project.join("compile_commands.json"),
r#"[{"directory":"/proj","command":"/usr/bin/clang++ -std=c++17 -c src/format.cc","file":"src/format.cc"}]"#,
).unwrap();
let header = include.join("format.h");
std::fs::write(&header, "").unwrap();
let languages = c_cpp_languages();
assert_eq!(
detect_language(&header, &languages),
Some("cpp".to_string())
);
}
#[test]
fn test_detect_language_h_stays_c_with_pure_c_compile_commands() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("cproj");
let include = project.join("include");
std::fs::create_dir_all(&include).unwrap();
std::fs::write(
project.join("compile_commands.json"),
r#"[{"directory":"/cproj","command":"/usr/bin/gcc -std=c11 -c src/lib.c","file":"src/lib.c"}]"#,
)
.unwrap();
let header = include.join("lib.h");
std::fs::write(&header, "").unwrap();
let languages = c_cpp_languages();
assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
}
#[test]
fn test_detect_language_h_stays_c_in_pure_c_tree() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("cproj");
std::fs::create_dir_all(&project).unwrap();
let header = project.join("lib.h");
std::fs::write(&header, "").unwrap();
std::fs::write(project.join("lib.c"), "").unwrap();
let languages = c_cpp_languages();
assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
}
#[test]
fn test_detect_language_h_stays_c_with_empty_compile_commands() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join("compile_commands.json"), "[]").unwrap();
let header = project.join("foo.h");
std::fs::write(&header, "").unwrap();
let languages = c_cpp_languages();
assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
}
#[test]
fn test_detect_language_h_promotes_on_cpp_std_flag_alone() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("proj");
let include = project.join("include");
std::fs::create_dir_all(&include).unwrap();
std::fs::write(
project.join("compile_commands.json"),
r#"[{"directory":"/proj","command":"/usr/bin/clang -std=c++20 -c src/x.C","file":"src/x.C"}]"#,
)
.unwrap();
let header = include.join("x.h");
std::fs::write(&header, "").unwrap();
let languages = c_cpp_languages();
assert_eq!(
detect_language(&header, &languages),
Some("cpp".to_string())
);
}
#[test]
fn test_detect_language_c_source_never_promoted() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let source = project.join("legacy.c");
std::fs::write(&source, "").unwrap();
std::fs::write(project.join("main.cpp"), "").unwrap();
let languages = c_cpp_languages();
assert_eq!(detect_language(&source, &languages), Some("c".to_string()));
}
#[test]
fn test_detect_language_h_no_promotion_without_cpp_config() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let header = project.join("widget.h");
std::fs::write(&header, "").unwrap();
std::fs::write(project.join("widget.cpp"), "").unwrap();
let mut languages = c_cpp_languages();
languages.remove("cpp");
assert_eq!(detect_language(&header, &languages), Some("c".to_string()));
}
#[test]
fn test_path_to_uri_basic() {
let uri = path_to_uri(Path::new("/tmp/test")).unwrap();
assert_eq!(uri.as_str(), "file:///tmp/test");
}
#[test]
fn test_path_to_uri_with_spaces() {
let uri = path_to_uri(Path::new("/tmp/my project/src")).unwrap();
assert_eq!(uri.as_str(), "file:///tmp/my%20project/src");
}
#[test]
fn dynamic_registration_enables_then_disables_inlay_hints() {
let mut caps = ServerCapabilitySummary::default();
assert!(!caps.inlay_hints);
let recognized = caps.apply_dynamic_registration("textDocument/inlayHint", None, true);
assert!(
recognized,
"inlayHint must be a recognized capability method"
);
assert!(
caps.inlay_hints,
"dynamic registration must enable inlay hints"
);
let recognized = caps.apply_dynamic_registration("textDocument/inlayHint", None, false);
assert!(recognized);
assert!(!caps.inlay_hints, "unregister must disable inlay hints");
}
#[test]
fn dynamic_registration_ignores_unknown_methods() {
let mut caps = ServerCapabilitySummary::default();
let recognized =
caps.apply_dynamic_registration("workspace/didChangeWatchedFiles", None, true);
assert!(!recognized);
}
#[test]
fn dynamic_registration_parses_completion_options() {
let mut caps = ServerCapabilitySummary::default();
let opts = serde_json::json!({
"triggerCharacters": [".", "::"],
"resolveProvider": true,
});
let recognized =
caps.apply_dynamic_registration("textDocument/completion", Some(&opts), true);
assert!(recognized);
assert!(caps.completion);
assert!(caps.completion_resolve);
assert_eq!(caps.completion_trigger_characters, vec![".", "::"]);
caps.apply_dynamic_registration("textDocument/completion", None, false);
assert!(!caps.completion);
assert!(!caps.completion_resolve);
assert!(caps.completion_trigger_characters.is_empty());
}
#[test]
fn dynamic_registration_parses_semantic_tokens_legend() {
let mut caps = ServerCapabilitySummary::default();
let opts = serde_json::json!({
"legend": {
"tokenTypes": ["namespace", "type"],
"tokenModifiers": ["declaration"],
},
"full": { "delta": true },
"range": true,
});
let recognized =
caps.apply_dynamic_registration("textDocument/semanticTokens", Some(&opts), true);
assert!(recognized);
assert!(caps.semantic_tokens_full);
assert!(caps.semantic_tokens_full_delta);
assert!(caps.semantic_tokens_range);
let legend = caps
.semantic_tokens_legend
.as_ref()
.expect("legend must be parsed from registration options");
assert_eq!(legend.token_types.len(), 2);
caps.apply_dynamic_registration("textDocument/semanticTokens", None, false);
assert!(!caps.semantic_tokens_full);
assert!(caps.semantic_tokens_legend.is_none());
}
#[test]
fn apply_dynamic_capabilities_reports_change_only_for_known_methods() {
let mut caps = ServerCapabilitySummary::default();
let known = caps.apply_dynamic_registration("textDocument/hover", None, true);
let unknown = caps.apply_dynamic_registration("some/unknownMethod", None, true);
assert!(known);
assert!(!unknown);
assert!(caps.hover);
}
}