use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::Translator;
use super::positioned::DisclosedDocument;
use crate::bridge::resources::{DiagnosticsResourceUri, parse_uri};
use crate::bridge::{
Access, ClientPath, InFlightGuard, LinePresence, OpenRequest, Position, WorkspacePath,
};
use crate::config::{
FileKey, LanguageId, NoServerReason, ServerId, ToolKind, ToolRouter, base_language_id,
};
use crate::error::{Error, ErrorChain, Result, ServerSpawnFailure};
use crate::lsp::LspClient;
use crate::util::{MAX_ECHOED_PATH_BYTES, escape_bounded, lock_std};
const FLUSH_PENDING_CLOSES_DEADLINE: std::time::Duration = std::time::Duration::from_secs(1);
#[derive(Debug)]
#[must_use = "dropping a PreparedDocument makes its document evictable mid-request"]
pub(super) struct PreparedDocument {
server_id: ServerId,
client: LspClient,
uri: lsp_types::Uri,
path: PathBuf,
_in_flight: InFlightGuard,
}
impl PreparedDocument {
pub(super) const fn server_id(&self) -> &ServerId {
&self.server_id
}
pub(super) const fn client(&self) -> &LspClient {
&self.client
}
pub(super) const fn uri(&self) -> &lsp_types::Uri {
&self.uri
}
pub(super) fn path(&self) -> &Path {
&self.path
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IndexingWait {
Wait,
Skip,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum IndexingGate {
Required(Capability),
FileLocal(FileLocalCapability),
}
impl IndexingGate {
pub(super) fn capability(self) -> Capability {
match self {
Self::Required(capability) => capability,
Self::FileLocal(capability) => capability.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FileLocalCapability {
DocumentSymbols,
FoldingRange,
SelectionRange,
DocumentHighlights,
FormatDocument,
FormatRange,
}
impl From<FileLocalCapability> for Capability {
fn from(capability: FileLocalCapability) -> Self {
match capability {
FileLocalCapability::DocumentSymbols => Self::DocumentSymbols,
FileLocalCapability::FoldingRange => Self::FoldingRange,
FileLocalCapability::SelectionRange => Self::SelectionRange,
FileLocalCapability::DocumentHighlights => Self::DocumentHighlights,
FileLocalCapability::FormatDocument => Self::FormatDocument,
FileLocalCapability::FormatRange => Self::FormatRange,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum DisclosedCapability {
SignatureHelp,
InlayHints,
CallHierarchy,
TypeHierarchy,
}
impl From<DisclosedCapability> for Capability {
fn from(capability: DisclosedCapability) -> Self {
match capability {
DisclosedCapability::SignatureHelp => Self::SignatureHelp,
DisclosedCapability::InlayHints => Self::InlayHints,
DisclosedCapability::CallHierarchy => Self::CallHierarchy,
DisclosedCapability::TypeHierarchy => Self::TypeHierarchy,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
Completions,
SignatureHelp,
InlayHints,
Hover,
Definition,
References,
Implementation,
TypeDefinition,
CallHierarchy,
Rename,
FormatDocument,
CodeActions,
DocumentSymbols,
WorkspaceSymbols,
Declaration,
TypeHierarchy,
PrepareRename,
DocumentHighlights,
FormatRange,
SelectionRange,
FoldingRange,
}
#[expect(
clippy::indexing_slicing,
reason = "compile-time check; the loop condition keeps `i` below `ALL.len()`"
)]
const _: () = {
assert!(Capability::ALL.len() == Capability::FoldingRange as usize + 1);
assert!(Capability::ALL.len() <= u32::BITS as usize);
let mut i = 0;
while i < Capability::ALL.len() {
assert!(Capability::ALL[i] as usize == i);
i += 1;
}
};
impl Capability {
pub(crate) const ALL: [Self; 21] = [
Self::Completions,
Self::SignatureHelp,
Self::InlayHints,
Self::Hover,
Self::Definition,
Self::References,
Self::Implementation,
Self::TypeDefinition,
Self::CallHierarchy,
Self::Rename,
Self::FormatDocument,
Self::CodeActions,
Self::DocumentSymbols,
Self::WorkspaceSymbols,
Self::Declaration,
Self::TypeHierarchy,
Self::PrepareRename,
Self::DocumentHighlights,
Self::FormatRange,
Self::SelectionRange,
Self::FoldingRange,
];
pub(crate) const fn tool_kind(self) -> ToolKind {
match self {
Self::Completions => ToolKind::Completions,
Self::SignatureHelp => ToolKind::SignatureHelp,
Self::InlayHints => ToolKind::InlayHints,
Self::Hover => ToolKind::Hover,
Self::Definition => ToolKind::Definition,
Self::References => ToolKind::References,
Self::Implementation => ToolKind::Implementation,
Self::TypeDefinition => ToolKind::TypeDefinition,
Self::CallHierarchy => ToolKind::CallHierarchy,
Self::Rename | Self::PrepareRename => ToolKind::Rename,
Self::FormatDocument => ToolKind::FormatDocument,
Self::CodeActions => ToolKind::CodeActions,
Self::DocumentSymbols => ToolKind::DocumentSymbols,
Self::WorkspaceSymbols => ToolKind::WorkspaceSymbols,
Self::Declaration => ToolKind::Declaration,
Self::TypeHierarchy => ToolKind::TypeHierarchy,
Self::DocumentHighlights => ToolKind::DocumentHighlights,
Self::FormatRange => ToolKind::FormatRange,
Self::SelectionRange => ToolKind::SelectionRange,
Self::FoldingRange => ToolKind::FoldingRange,
}
}
pub(crate) const fn is_primary(self) -> bool {
!matches!(self, Self::PrepareRename)
}
pub(crate) const fn for_tool(tool: ToolKind) -> Option<Self> {
Self::find_gating(&Self::ALL, tool)
}
const fn find_gating(candidates: &[Self], tool: ToolKind) -> Option<Self> {
match candidates {
[first, rest @ ..] => {
if first.is_primary() && first.tool_kind() as u8 == tool as u8 {
Some(*first)
} else {
Self::find_gating(rest, tool)
}
}
[] => None,
}
}
pub(crate) const fn is_available(self, caps: Option<&lsp_types::ServerCapabilities>) -> bool {
match caps {
Some(caps) => self.is_supported(caps),
None => true,
}
}
pub(crate) const fn name(self) -> &'static str {
match self {
Self::Completions => "completionProvider",
Self::SignatureHelp => "signatureHelpProvider",
Self::InlayHints => "inlayHintProvider",
Self::Hover => "hoverProvider",
Self::Definition => "definitionProvider",
Self::References => "referencesProvider",
Self::Implementation => "implementationProvider",
Self::TypeDefinition => "typeDefinitionProvider",
Self::CallHierarchy => "callHierarchyProvider",
Self::Rename => "renameProvider",
Self::FormatDocument => "documentFormattingProvider",
Self::CodeActions => "codeActionProvider",
Self::DocumentSymbols => "documentSymbolProvider",
Self::WorkspaceSymbols => "workspaceSymbolProvider",
Self::Declaration => "declarationProvider",
Self::TypeHierarchy => "typeHierarchyProvider",
Self::PrepareRename => "renameProvider.prepareProvider",
Self::DocumentHighlights => "documentHighlightProvider",
Self::FormatRange => "documentRangeFormattingProvider",
Self::SelectionRange => "selectionRangeProvider",
Self::FoldingRange => "foldingRangeProvider",
}
}
#[allow(clippy::too_many_lines, reason = "one match arm per capability")]
pub(crate) const fn is_supported(self, caps: &lsp_types::ServerCapabilities) -> bool {
match self {
Self::Completions => caps.completion_provider.is_some(),
Self::SignatureHelp => caps.signature_help_provider.is_some(),
Self::InlayHints => matches!(
caps.inlay_hint_provider,
Some(
lsp_types::InlayHintProvider::Bool(true)
| lsp_types::InlayHintProvider::InlayHintOptions(_)
| lsp_types::InlayHintProvider::InlayHintRegistrationOptions(_)
)
),
Self::Hover => matches!(
caps.hover_provider,
Some(
lsp_types::HoverProvider::Bool(true)
| lsp_types::HoverProvider::HoverOptions(_)
)
),
Self::Definition => matches!(
caps.definition_provider,
Some(
lsp_types::DefinitionProvider::Bool(true)
| lsp_types::DefinitionProvider::DefinitionOptions(_)
)
),
Self::References => matches!(
caps.references_provider,
Some(
lsp_types::ReferencesProvider::Bool(true)
| lsp_types::ReferencesProvider::ReferenceOptions(_)
)
),
Self::Implementation => matches!(
caps.implementation_provider,
Some(
lsp_types::ImplementationProvider::Bool(true)
| lsp_types::ImplementationProvider::ImplementationOptions(_)
| lsp_types::ImplementationProvider::ImplementationRegistrationOptions(_)
)
),
Self::TypeDefinition => matches!(
caps.type_definition_provider,
Some(
lsp_types::TypeDefinitionProvider::Bool(true)
| lsp_types::TypeDefinitionProvider::TypeDefinitionOptions(_)
| lsp_types::TypeDefinitionProvider::TypeDefinitionRegistrationOptions(_)
)
),
Self::CallHierarchy => matches!(
caps.call_hierarchy_provider,
Some(
lsp_types::CallHierarchyProvider::Bool(true)
| lsp_types::CallHierarchyProvider::CallHierarchyOptions(_)
| lsp_types::CallHierarchyProvider::CallHierarchyRegistrationOptions(_)
)
),
Self::Rename => matches!(
caps.rename_provider,
Some(
lsp_types::RenameProvider::Bool(true)
| lsp_types::RenameProvider::RenameOptions(_)
)
),
Self::FormatDocument => matches!(
caps.document_formatting_provider,
Some(
lsp_types::DocumentFormattingProvider::Bool(true)
| lsp_types::DocumentFormattingProvider::DocumentFormattingOptions(_)
)
),
Self::CodeActions => matches!(
caps.code_action_provider,
Some(
lsp_types::CodeActionProvider::Bool(true)
| lsp_types::CodeActionProvider::CodeActionOptions(_)
)
),
Self::DocumentSymbols => matches!(
caps.document_symbol_provider,
Some(
lsp_types::DocumentSymbolProvider::Bool(true)
| lsp_types::DocumentSymbolProvider::DocumentSymbolOptions(_)
)
),
Self::WorkspaceSymbols => matches!(
caps.workspace_symbol_provider,
Some(
lsp_types::WorkspaceSymbolProvider::Bool(true)
| lsp_types::WorkspaceSymbolProvider::WorkspaceSymbolOptions(_)
)
),
Self::Declaration => matches!(
caps.declaration_provider,
Some(
lsp_types::DeclarationProvider::Bool(true)
| lsp_types::DeclarationProvider::DeclarationOptions(_)
| lsp_types::DeclarationProvider::DeclarationRegistrationOptions(_)
)
),
Self::TypeHierarchy => matches!(
caps.type_hierarchy_provider,
Some(
lsp_types::TypeHierarchyProvider::Bool(true)
| lsp_types::TypeHierarchyProvider::TypeHierarchyOptions(_)
| lsp_types::TypeHierarchyProvider::TypeHierarchyRegistrationOptions(_)
)
),
Self::PrepareRename => matches!(
caps.rename_provider,
Some(lsp_types::RenameProvider::RenameOptions(
lsp_types::RenameOptions {
prepare_provider: Some(true),
..
}
))
),
Self::DocumentHighlights => matches!(
caps.document_highlight_provider,
Some(
lsp_types::DocumentHighlightProvider::Bool(true)
| lsp_types::DocumentHighlightProvider::DocumentHighlightOptions(_)
)
),
Self::FormatRange => matches!(
caps.document_range_formatting_provider,
Some(
lsp_types::DocumentRangeFormattingProvider::Bool(true)
| lsp_types::DocumentRangeFormattingProvider::DocumentRangeFormattingOptions(_)
)
),
Self::SelectionRange => matches!(
caps.selection_range_provider,
Some(
lsp_types::SelectionRangeProvider::Bool(true)
| lsp_types::SelectionRangeProvider::SelectionRangeOptions(_)
| lsp_types::SelectionRangeProvider::SelectionRangeRegistrationOptions(_)
)
),
Self::FoldingRange => matches!(
caps.folding_range_provider,
Some(
lsp_types::FoldingRangeProvider::Bool(true)
| lsp_types::FoldingRangeProvider::FoldingRangeOptions(_)
| lsp_types::FoldingRangeProvider::FoldingRangeRegistrationOptions(_)
)
),
}
}
}
impl std::fmt::Display for Capability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
impl serde::Serialize for Capability {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(self.name())
}
}
impl schemars::JsonSchema for Capability {
fn schema_name() -> std::borrow::Cow<'static, str> {
"Capability".into()
}
fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"description": "LSP server capability the tool is gated on."
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct LanguageCandidates {
language: LanguageId,
base: Option<LanguageId>,
}
impl LanguageCandidates {
pub(super) fn new(language: LanguageId) -> Self {
let base = base_language_id(&language);
Self { language, base }
}
pub(super) const fn language(&self) -> &LanguageId {
&self.language
}
pub(super) fn iter(&self) -> impl Iterator<Item = &LanguageId> {
std::iter::once(&self.language).chain(self.base.as_ref())
}
}
#[derive(Debug)]
pub(super) enum RouteLookup<T> {
Registered(ServerId, T),
Initializing(ServerId),
Failed(Box<ServerSpawnFailure>),
Dangling {
language: LanguageId,
server_id: ServerId,
},
Unrouted,
}
pub(super) fn lookup_route<T>(
candidates: &LanguageCandidates,
resolve: impl Fn(&str) -> Option<ServerId>,
registered: impl Fn(&ServerId) -> Option<T>,
is_expected: impl Fn(&ServerId) -> bool,
pending_catch_all: impl Fn(&str) -> Option<ServerId>,
startup_failure: impl Fn(&ServerId) -> Option<ServerSpawnFailure>,
) -> RouteLookup<T> {
for language in candidates.iter() {
let Some(server_id) = resolve(language.as_str()) else {
continue;
};
return if let Some(found) = registered(&server_id) {
RouteLookup::Registered(server_id, found)
} else if is_expected(&server_id) {
RouteLookup::Initializing(server_id)
} else if let Some(catch_all) = pending_catch_all(language.as_str()) {
RouteLookup::Initializing(catch_all)
} else if let Some(failure) = startup_failure(&server_id) {
RouteLookup::Failed(Box::new(failure))
} else {
RouteLookup::Dangling {
language: language.clone(),
server_id,
}
};
}
RouteLookup::Unrouted
}
fn pending_catch_all(
router: &ToolRouter,
servers: &super::servers::Servers,
language: &str,
) -> Option<ServerId> {
router
.catch_all_for(language)
.filter(|catch_all| servers.is_expected(catch_all))
.cloned()
}
#[derive(Debug)]
pub enum DiagnosticsRoute {
Live(ServerId),
Initializing(ServerId),
FailedToStart(Box<ServerSpawnFailure>),
Unrouted,
}
impl DiagnosticsRoute {
pub(crate) const fn server_id(&self) -> Option<&ServerId> {
match self {
Self::Live(id) | Self::Initializing(id) => Some(id),
Self::FailedToStart(_) | Self::Unrouted => None,
}
}
pub(crate) const fn is_failed(&self) -> bool {
matches!(self, Self::FailedToStart(_))
}
pub(crate) fn into_startup_failure(self) -> Option<Box<ServerSpawnFailure>> {
match self {
Self::FailedToStart(failure) => Some(failure),
Self::Live(_) | Self::Initializing(_) | Self::Unrouted => None,
}
}
pub(crate) fn into_read_result(self) -> Result<Option<ServerId>> {
match self {
Self::Live(id) => Ok(Some(id)),
Self::Unrouted => Ok(None),
Self::Initializing(server_id) => Err(Error::ServerInitializing { server_id }),
Self::FailedToStart(failure) => Err(Error::ServerFailedToStart(failure)),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum WorkspaceRouteLookup {
Registered(ServerId),
Initializing(ServerId),
Dangling(ServerId),
AllInitializing,
NothingConfigured,
NoClaimant,
}
pub(super) fn lookup_workspace_route(
resolve: impl FnOnce() -> std::result::Result<ServerId, NoServerReason>,
is_registered: impl Fn(&ServerId) -> bool,
is_expected: impl Fn(&ServerId) -> bool,
expected_is_empty: impl FnOnce() -> bool,
) -> WorkspaceRouteLookup {
match resolve() {
Ok(id) if is_registered(&id) => WorkspaceRouteLookup::Registered(id),
Ok(id) if is_expected(&id) => WorkspaceRouteLookup::Initializing(id),
Ok(id) => WorkspaceRouteLookup::Dangling(id),
Err(NoServerReason::NothingRegistered) if expected_is_empty() => {
WorkspaceRouteLookup::NothingConfigured
}
Err(NoServerReason::NothingRegistered) => WorkspaceRouteLookup::AllInitializing,
Err(NoServerReason::NoClaimant) => WorkspaceRouteLookup::NoClaimant,
}
}
pub(super) fn check_capability(
server_id: &ServerId,
caps: Option<&lsp_types::ServerCapabilities>,
capability: Capability,
) -> Result<()> {
capability
.is_available(caps)
.ok_or_else(|| Error::CapabilityNotSupported {
server_id: server_id.clone(),
capability,
})
}
impl Translator {
pub(crate) async fn validate_path(&self, path: &ClientPath) -> Result<WorkspacePath> {
self.workspace_roots.validate(path).await
}
pub(super) async fn resolve_client_for_file(
&self,
path: &Path,
tool: ToolKind,
) -> Result<(ServerId, LspClient)> {
let (id, client) = self.client_for_file(path, tool)?;
self.respawn_if_dead(&id).await?;
let client = lock_std(&self.servers).client(&id).unwrap_or(client);
Ok((id, client))
}
pub(super) fn client_for_file(
&self,
path: &Path,
tool: ToolKind,
) -> Result<(ServerId, LspClient)> {
let candidates = self.language_candidates(path);
let router = self.router_snapshot();
let lookup = {
let servers = lock_std(&self.servers);
lookup_route(
&candidates,
|lang| router.resolve(lang, tool).cloned(),
|id| servers.client(id),
|id| servers.is_expected(id),
|lang| pending_catch_all(&router, &servers, lang),
|id| servers.failure(id).cloned(),
)
};
match lookup {
RouteLookup::Registered(id, client) => Ok((id, client)),
RouteLookup::Initializing(server_id) => Err(Error::ServerInitializing { server_id }),
RouteLookup::Failed(failure) => Err(Error::ServerFailedToStart(failure)),
RouteLookup::Dangling {
language,
server_id,
} => {
tracing::error!(
"router route names server '{server_id}' for tool '{tool}' that is neither \
registered nor expected"
);
Err(Error::NoServerForTool {
language_id: language,
tool,
})
}
RouteLookup::Unrouted => {
if let Some(failure) = self.startup_failure_for_candidates(&candidates, tool) {
return Err(Error::ServerFailedToStart(Box::new(failure)));
}
let has_language = {
let router = lock_std(&self.router);
candidates
.iter()
.any(|lang| router.has_language(lang.as_str()))
};
let language = candidates.language().clone();
if has_language {
Err(Error::NoServerForTool {
language_id: language,
tool,
})
} else {
Err(Error::NoServerForLanguage {
language,
file: FileKey::of(path),
patterns: Arc::clone(&self.file_patterns),
})
}
}
}
}
pub(super) fn language_candidates(&self, path: &Path) -> LanguageCandidates {
LanguageCandidates::new(self.language_map.detect(path))
}
fn startup_failure_for_candidates(
&self,
candidates: &LanguageCandidates,
tool: ToolKind,
) -> Option<ServerSpawnFailure> {
candidates.iter().find_map(|lang| {
let id = self.configured_router.resolve(lang.as_str(), tool)?;
self.startup_failure(id)
})
}
#[must_use]
pub(crate) fn diagnostics_route_for_path(&self, path: &Path) -> DiagnosticsRoute {
let candidates = self.language_candidates(path);
let router = self.router_snapshot();
let lookup = {
let servers = lock_std(&self.servers);
lookup_route(
&candidates,
|lang| router.resolve(lang, ToolKind::Diagnostics).cloned(),
|id| servers.client(id).map(|_| ()),
|id| servers.is_expected(id),
|lang| pending_catch_all(&router, &servers, lang),
|id| servers.failure(id).cloned(),
)
};
match lookup {
RouteLookup::Registered(id, ()) => DiagnosticsRoute::Live(id),
RouteLookup::Initializing(id) => DiagnosticsRoute::Initializing(id),
RouteLookup::Failed(failure) => DiagnosticsRoute::FailedToStart(failure),
RouteLookup::Dangling { server_id, .. } => {
tracing::error!(
"router diagnostics route names server '{server_id}' that is neither \
registered nor expected"
);
DiagnosticsRoute::Unrouted
}
RouteLookup::Unrouted => self
.startup_failure_for_candidates(&candidates, ToolKind::Diagnostics)
.map_or(DiagnosticsRoute::Unrouted, |failure| {
DiagnosticsRoute::FailedToStart(Box::new(failure))
}),
}
}
#[must_use]
pub(crate) fn diagnostics_route_for_uri(
&self,
uri: &DiagnosticsResourceUri,
) -> DiagnosticsRoute {
parse_uri(uri.as_str()).map_or(DiagnosticsRoute::Unrouted, |path| {
self.diagnostics_route_for_path(path.as_path())
})
}
async fn resolve_validated_client_for_file(
&self,
file_path: &ClientPath,
tool: ToolKind,
) -> Result<(ServerId, LspClient, WorkspacePath)> {
let validated_path = self.validate_path(file_path).await?;
let (server_id, client) = self
.resolve_client_for_file(validated_path.as_path(), tool)
.await?;
Ok((server_id, client, validated_path))
}
async fn resolve_validated_client_for_path(
&self,
path: &WorkspacePath,
tool: ToolKind,
) -> Result<(ServerId, LspClient, WorkspacePath)> {
let (server_id, client) = self.resolve_client_for_file(path.as_path(), tool).await?;
Ok((server_id, client, path.clone()))
}
#[cfg(test)]
pub(super) async fn prepare_document(
&self,
file_path: &ClientPath,
tool: ToolKind,
) -> Result<PreparedDocument> {
let path = self.validate_path(file_path).await?;
self.prepare_document_for_path(&path, tool).await
}
pub(super) async fn prepare_document_for_path(
&self,
path: &WorkspacePath,
tool: ToolKind,
) -> Result<PreparedDocument> {
let (server_id, client, validated_path) =
self.resolve_validated_client_for_path(path, tool).await?;
self.open_prepared(server_id, client, &validated_path, Access::Use)
.await
}
pub(super) async fn open_prepared(
&self,
server_id: ServerId,
client: LspClient,
validated_path: &WorkspacePath,
access: Access,
) -> Result<PreparedDocument> {
let in_flight = self
.document_tracker
.mark_in_flight(validated_path.as_path());
let result = self
.document_tracker
.ensure_open(OpenRequest::new(
validated_path,
&self.workspace_roots,
&server_id,
&client,
access,
))
.await;
self.flush_pending_closes().await;
let uri = result?;
Ok(PreparedDocument {
server_id,
client,
uri,
path: validated_path.as_path().to_path_buf(),
_in_flight: in_flight,
})
}
pub(super) fn require_line_in_document(
&self,
doc: &PreparedDocument,
position: Position,
) -> Result<()> {
let line = position.line();
match self
.document_tracker
.line_presence(doc.path(), line.get().saturating_sub(1))
{
LinePresence::Present => Ok(()),
LinePresence::Beyond { last_line } => {
Err(Error::PositionBeyondDocument { line, last_line })
}
LinePresence::Untracked => {
tracing::debug!(
line = line.get(),
"skipping the line check for an untracked document"
);
Ok(())
}
}
}
pub(super) async fn prepare_gated_document(
&self,
file_path: &ClientPath,
indexing_gate: IndexingGate,
) -> Result<PreparedDocument> {
let (server_id, client, validated_path) = self
.resolve_validated_client_for_file(file_path, indexing_gate.capability().tool_kind())
.await?;
self.finish_prepare_gated_document(server_id, client, validated_path, indexing_gate)
.await
}
pub(super) async fn prepare_positioned_document(
&self,
file_path: &ClientPath,
indexing_gate: IndexingGate,
positions: &[Position],
) -> Result<PreparedDocument> {
let doc = self
.prepare_gated_document(file_path, indexing_gate)
.await?;
self.require_lines_in_document(&doc, positions)?;
Ok(doc)
}
pub(super) async fn prepare_gated_document_for_path(
&self,
path: &WorkspacePath,
indexing_gate: IndexingGate,
) -> Result<PreparedDocument> {
let (server_id, client, validated_path) = self
.resolve_validated_client_for_path(path, indexing_gate.capability().tool_kind())
.await?;
self.finish_prepare_gated_document(server_id, client, validated_path, indexing_gate)
.await
}
pub(super) async fn prepare_disclosed_document(
&self,
file_path: &ClientPath,
capability: DisclosedCapability,
positions: &[Position],
) -> Result<DisclosedDocument<'_>> {
let capability = Capability::from(capability);
let (server_id, client, validated_path) = self
.resolve_validated_client_for_file(file_path, capability.tool_kind())
.await?;
let doc = self
.open_checked(
server_id,
client,
&validated_path,
capability,
IndexingWait::Skip,
)
.await?;
self.require_lines_in_document(&doc, positions)?;
Ok(DisclosedDocument::new(self, doc))
}
pub(super) async fn require_routed_capability(
&self,
file_path: &ClientPath,
capability: Capability,
) -> Result<()> {
let (server_id, _client, _path) = self
.resolve_validated_client_for_file(file_path, capability.tool_kind())
.await?;
self.require_capability(&server_id, capability)
}
async fn finish_prepare_gated_document(
&self,
server_id: ServerId,
client: LspClient,
validated_path: WorkspacePath,
indexing_gate: IndexingGate,
) -> Result<PreparedDocument> {
let wait = match indexing_gate {
IndexingGate::Required(_) => IndexingWait::Wait,
IndexingGate::FileLocal(_) => IndexingWait::Skip,
};
self.open_checked(
server_id,
client,
&validated_path,
indexing_gate.capability(),
wait,
)
.await
}
async fn open_checked(
&self,
server_id: ServerId,
client: LspClient,
validated_path: &WorkspacePath,
capability: Capability,
wait: IndexingWait,
) -> Result<PreparedDocument> {
self.require_capability(&server_id, capability)?;
if wait == IndexingWait::Wait {
self.wait_for_indexing_ready(&server_id).await?;
}
self.open_prepared(server_id, client, validated_path, Access::Use)
.await
}
fn require_lines_in_document(
&self,
doc: &PreparedDocument,
positions: &[Position],
) -> Result<()> {
positions
.iter()
.try_for_each(|position| self.require_line_in_document(doc, *position))
}
async fn flush_pending_closes(&self) {
let flush = async {
for path in self.document_tracker.pending_close_paths() {
let Some(mut claim) = self.document_tracker.try_claim_pending_close(&path) else {
continue;
};
let owed: Vec<ServerId> = claim.servers().cloned().collect();
for server_id in owed {
let Some(client) = lock_std(&self.servers).client(&server_id) else {
claim.settle(&server_id);
continue;
};
let sent = client
.notify_typed::<lsp_types::DidCloseTextDocumentNotification>(
lsp_types::DidCloseTextDocumentParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: claim.uri.clone(),
},
},
)
.await;
claim.settle(&server_id);
if let Err(err) = sent {
tracing::warn!(
%server_id,
path = %claim.path.display(),
error = %ErrorChain(&err),
"failed to notify evicted document's server of textDocument/didClose; \
dropping the close"
);
}
}
}
};
if tokio::time::timeout(FLUSH_PENDING_CLOSES_DEADLINE, flush)
.await
.is_err()
{
tracing::warn!(
deadline = ?FLUSH_PENDING_CLOSES_DEADLINE,
"flushing evicted documents' didClose notifications hit the deadline; paths not yet \
reached and the in-flight path's unsent closes stay pending"
);
}
}
pub(super) fn require_capability(
&self,
server_id: &ServerId,
capability: Capability,
) -> Result<()> {
let servers = lock_std(&self.servers);
check_capability(
server_id,
servers
.server(server_id)
.map(crate::lsp::LspServer::capabilities),
capability,
)
}
pub(super) fn code_action_resolve_supported(&self, server_id: &ServerId) -> bool {
let servers = lock_std(&self.servers);
matches!(
servers
.server(server_id)
.map(crate::lsp::LspServer::capabilities)
.and_then(|caps| caps.code_action_provider.as_ref()),
Some(lsp_types::CodeActionProvider::CodeActionOptions(
lsp_types::CodeActionOptions {
resolve_provider: Some(true),
..
}
))
)
}
pub(super) async fn parse_file_uri(&self, uri: &lsp_types::Uri) -> Result<WorkspacePath> {
let path = crate::bridge::state::uri_to_path(uri).ok_or_else(|| {
Error::InvalidToolParams(format!(
"Invalid URI, expected an absolute file:// URI but got: {}",
escape_bounded(uri.as_ref(), MAX_ECHOED_PATH_BYTES)
))
})?;
self.validate_path(&ClientPath::try_from(path)?).await
}
}
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::{assert_matches, fs};
use tempfile::TempDir;
use tokio::io::BufReader;
use tokio::sync::Mutex;
use tokio::time::{Duration, timeout};
use url::Url;
use super::*;
use crate::bridge::translator::dto::Position;
use crate::bridge::translator::testing::*;
use crate::bridge::{NotificationCache, ResultContext, WorkspaceRoots};
use crate::config::{
DocumentLimit, FileExtension, FilePattern, IndexingReadyTimeoutSecs, LanguageId,
LanguageMap, LspServerConfig, PositionEncodings, SearchDepth, ServerCommand, SizeLimit,
TimeoutSecs, ToolRouter, ToolSet,
};
use crate::error::Error;
use crate::lsp::LspServer;
use crate::test_lsp::client_path;
type JsonValue = serde_json::Value;
#[test]
fn test_indexing_gate_capability_is_the_gated_capability() {
assert_eq!(
IndexingGate::Required(Capability::Hover).capability(),
Capability::Hover
);
let file_local = [
(
FileLocalCapability::DocumentSymbols,
Capability::DocumentSymbols,
),
(FileLocalCapability::FoldingRange, Capability::FoldingRange),
(
FileLocalCapability::SelectionRange,
Capability::SelectionRange,
),
(
FileLocalCapability::DocumentHighlights,
Capability::DocumentHighlights,
),
(
FileLocalCapability::FormatDocument,
Capability::FormatDocument,
),
(FileLocalCapability::FormatRange, Capability::FormatRange),
];
for (file_local, capability) in file_local {
assert_eq!(IndexingGate::FileLocal(file_local).capability(), capability);
}
}
#[test]
fn test_client_for_file_server_initializing_when_expected() {
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = LanguageMap::default().detect(&path);
let id = ServerId::from(lang.clone());
let translator = Translator::new().with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::new(lang).unwrap(),
)]));
let mut expected = HashSet::new();
expected.insert(id.clone());
translator.set_expected_servers(expected);
let err = translator
.client_for_file(&path, ToolKind::Hover)
.unwrap_err();
assert_matches!(err, Error::ServerInitializing { server_id } if server_id == id);
}
#[test]
fn test_client_for_file_no_server_when_not_expected() {
let translator = Translator::new();
let path = PathBuf::from("/ws/Assets/Scripts/Player.cs");
let lang = translator.language_map.detect(&path);
let err = translator
.client_for_file(&path, ToolKind::Hover)
.unwrap_err();
assert_matches!(err, Error::NoServerForLanguage { ref language, .. } if *language == lang);
}
use crate::test_lsp::test_extensions;
fn not_found_failure(id: &ServerId, language: &str, command: &str) -> ServerSpawnFailure {
ServerSpawnFailure {
server_id: id.clone(),
language_id: LanguageId::new(language).unwrap(),
command: ServerCommand::new(command).unwrap(),
reason: crate::error::StartupFailure::Spawn(Arc::new(Error::ServerNotFound {
command: ServerCommand::new(command).unwrap(),
io: std::io::Error::from(std::io::ErrorKind::NotFound),
})),
}
}
fn router_config(
language: &str,
name: &str,
handles: Option<Vec<ToolKind>>,
) -> LspServerConfig {
LspServerConfig {
language_id: LanguageId::new(language).unwrap(),
command: ServerCommand::from_static("sh").into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(5).unwrap(),
request_timeout_seconds: TimeoutSecs::new(5).unwrap(),
heuristics: None,
name: Some(ServerId::new(name).unwrap()),
handles: handles.map(|tools| ToolSet::new(tools).unwrap()),
indexing: crate::bridge::IndexingPolicy::Auto,
}
}
#[test]
fn test_client_for_file_reports_startup_failure_of_sole_server() {
let id = ServerId::from_static("rust");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("rust"),
)]));
translator.record_startup_failures(&[not_found_failure(&id, "rust", "rust-analyzer")]);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let err = translator
.client_for_file(Path::new("/ws/main.rs"), ToolKind::Hover)
.unwrap_err();
let Error::ServerFailedToStart(failure) = &err else {
panic!("expected ServerFailedToStart, got {err:?}");
};
assert_eq!(failure.server_id, id);
let message = err.to_string();
assert!(
message.contains("rustup component add rust-analyzer"),
"{message}"
);
}
#[test]
fn test_client_for_file_ignores_startup_failure_of_other_language() {
let id = ServerId::from_static("rust");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("rust"),
)]));
translator.record_startup_failures(&[not_found_failure(&id, "rust", "rust-analyzer")]);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let err = translator
.client_for_file(Path::new("/ws/script.py"), ToolKind::Hover)
.unwrap_err();
assert_matches!(err, Error::NoServerForLanguage { .. }, "got {err:?}");
}
#[tokio::test]
async fn test_client_for_file_live_catch_all_wins_over_failed_explicit_server() {
let configs = [
router_config("rust", "hover-only", Some(vec![ToolKind::Hover])),
router_config("rust", "catch-all", None),
];
let router = ToolRouter::from_configs(configs.iter()).unwrap();
let hover_id = ServerId::from_static("hover-only");
let live_id = ServerId::from_static("catch-all");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(router);
let (client, _server) = fake_lsp_client();
translator.register_client(live_id.clone(), client);
translator.record_startup_failures(&[not_found_failure(&hover_id, "rust", "sh")]);
translator.rebind_router(&HashSet::from([live_id.clone()]));
translator.clear_expected_servers();
let (id, _client) = translator
.client_for_file(Path::new("/ws/main.rs"), ToolKind::Hover)
.unwrap();
assert_eq!(id, live_id);
}
#[tokio::test]
async fn test_client_for_file_reports_failed_catch_all_for_unclaimed_tool() {
let configs = [
router_config("rust", "hover-only", Some(vec![ToolKind::Hover])),
router_config("rust", "catch-all", None),
];
let router = ToolRouter::from_configs(configs.iter()).unwrap();
let hover_id = ServerId::from_static("hover-only");
let failed_id = ServerId::from_static("catch-all");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(router);
let (client, _server) = fake_lsp_client();
translator.register_client(hover_id.clone(), client);
translator.record_startup_failures(&[not_found_failure(&failed_id, "rust", "sh")]);
translator.rebind_router(&HashSet::from([hover_id]));
translator.clear_expected_servers();
let err = translator
.client_for_file(Path::new("/ws/main.rs"), ToolKind::Definition)
.unwrap_err();
assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == failed_id,
"got {err:?}"
);
}
#[test]
fn test_client_for_file_reports_startup_failure_through_react_base_language() {
let id = ServerId::from_static("typescript");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("typescript"),
)]));
translator.record_startup_failures(&[not_found_failure(
&id,
"typescript",
"typescript-language-server",
)]);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let err = translator
.client_for_file(Path::new("/ws/app.tsx"), ToolKind::Hover)
.unwrap_err();
assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == id,
"got {err:?}"
);
}
#[tokio::test]
async fn test_validate_path_no_workspace_roots_rejects_any_path() {
let translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator.validate_path(&client_path(test_file)).await;
assert_matches!(result, Err(Error::NoWorkspaceRoots(_)));
}
#[tokio::test]
async fn test_validate_path_within_workspace() {
let mut translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path().to_path_buf();
translator.set_workspace_roots(WorkspaceRoots::from_paths(&[workspace_root]).unwrap());
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator.validate_path(&client_path(test_file)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_validate_path_outside_workspace() {
let mut translator = Translator::new();
let temp_dir1 = TempDir::new().unwrap();
let temp_dir2 = TempDir::new().unwrap();
translator.set_workspace_roots(
WorkspaceRoots::from_paths(&[temp_dir1.path().to_path_buf()]).unwrap(),
);
let test_file = temp_dir2.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let result = translator.validate_path(&client_path(test_file)).await;
assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
}
#[test]
fn test_validate_path_nonexistent_outside_path_is_outside_workspace() {
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let roots = WorkspaceRoots::from_paths(&[root.path().to_path_buf()]).unwrap();
let result = roots
.validate_blocking(&client_path(outside.path().join("missing.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
}
#[test]
fn test_validate_path_dotdot_escape_is_outside_workspace() {
let root = TempDir::new().unwrap();
let roots = WorkspaceRoots::from_paths(&[root.path().to_path_buf()]).unwrap();
let result = roots
.validate_blocking(&client_path(root.path().join("../escape.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
}
#[test]
fn test_validate_path_dot_segments_inside_root_are_accepted() {
let root = TempDir::new().unwrap();
fs::create_dir(root.path().join("a")).unwrap();
fs::write(root.path().join("b.rs"), "").unwrap();
let roots = WorkspaceRoots::from_paths(&[root.path().to_path_buf()]).unwrap();
let result = roots
.validate_blocking(&client_path(root.path().join("./a/../b.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_eq!(
result.unwrap(),
dunce::canonicalize(root.path().join("b.rs")).unwrap()
);
}
#[cfg(unix)]
#[test]
fn test_validate_path_symlink_alias_of_root_is_accepted() {
let AliasFixture {
dir: _dir,
real,
alias,
roots,
} = alias_fixture();
fs::write(real.join("a.rs"), "").unwrap();
let result = roots
.validate_blocking(&client_path(alias.join("a.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_eq!(
result.unwrap(),
dunce::canonicalize(real.join("a.rs")).unwrap()
);
}
#[cfg(unix)]
#[test]
fn test_validate_path_dotdot_after_alias_root_is_outside_workspace() {
let AliasFixture {
dir, alias, roots, ..
} = alias_fixture();
fs::write(dir.path().join("outside.rs"), "").unwrap();
let result = roots
.validate_blocking(&client_path(alias.join("../outside.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
}
#[cfg(unix)]
#[test]
fn test_validate_path_missing_file_under_alias_root_is_file_io() {
let AliasFixture {
dir: _dir,
alias,
roots,
..
} = alias_fixture();
let result = roots
.validate_blocking(&client_path(alias.join("missing.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_matches!(result, Err(Error::FileIo { .. }), "{result:?}");
}
#[cfg(unix)]
#[test]
fn test_validate_path_escaping_symlink_under_alias_root_is_outside_workspace() {
let AliasFixture {
dir: _dir,
real,
alias,
roots,
} = alias_fixture();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("secret.rs"), "").unwrap();
std::os::unix::fs::symlink(outside.path(), real.join("link")).unwrap();
let result = roots
.validate_blocking(&client_path(alias.join("link/secret.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
}
#[cfg(unix)]
#[test]
fn test_validate_path_symlink_escaping_root_is_outside_workspace() {
let root = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
fs::write(outside.path().join("secret.rs"), "").unwrap();
std::os::unix::fs::symlink(outside.path(), root.path().join("link")).unwrap();
let roots = WorkspaceRoots::from_paths(&[root.path().to_path_buf()]).unwrap();
let result = roots
.validate_blocking(&client_path(root.path().join("link/secret.rs")))
.map(crate::bridge::WorkspacePath::into_path_buf);
assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
}
#[tokio::test]
async fn test_handle_hover_blocked_when_path_outside_workspace() {
let workspace_dir = TempDir::new().unwrap();
let outside_dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&workspace_dir,
&server_id,
lsp_types::ServerCapabilities {
hover_provider: Some(lsp_types::HoverProvider::Bool(true)),
..Default::default()
},
);
let outside_path = outside_dir.path().join("outside.rs");
fs::write(&outside_path, "fn outside() {}").unwrap();
let result = translator
.handle_hover(
client_path(outside_path.to_string_lossy().into_owned()),
pos(1, 1),
)
.await;
assert_matches!(result, Err(Error::PathOutsideWorkspace(_)));
}
#[tokio::test]
async fn test_parse_file_uri_invalid_scheme() {
let translator = Translator::new();
let uri: lsp_types::Uri = lsp_types::Uri::from("http://example.com/file.rs");
let result = translator.parse_file_uri(&uri).await;
assert_matches!(result, Err(Error::InvalidToolParams(_)));
}
#[tokio::test]
async fn test_parse_file_uri_valid_scheme() {
let mut translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
translator.set_workspace_roots(
WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
);
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let file_url = Url::from_file_path(&test_file).unwrap();
let uri: lsp_types::Uri = lsp_types::Uri::from(file_url.as_str());
let result = translator.parse_file_uri(&uri).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_parse_file_uri_percent_decodes_space_and_non_ascii() {
let mut translator = Translator::new();
let temp_dir = TempDir::new().unwrap();
translator.set_workspace_roots(
WorkspaceRoots::from_paths(&[temp_dir.path().to_path_buf()]).unwrap(),
);
let test_file = temp_dir.path().join("my file café.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let file_url = Url::from_file_path(&test_file).unwrap();
assert!(
file_url.as_str().contains("%20"),
"test fixture must exercise percent-encoding"
);
let uri: lsp_types::Uri = lsp_types::Uri::from(file_url.as_str());
let result = translator.parse_file_uri(&uri).await.unwrap();
assert_eq!(result.as_path(), dunce::canonicalize(&test_file).unwrap());
}
#[tokio::test]
async fn test_parse_file_uri_rejects_authority() {
let translator = Translator::new();
let uri: lsp_types::Uri = lsp_types::Uri::from("file://host/some/path.rs");
let result = translator.parse_file_uri(&uri).await;
assert_matches!(result, Err(Error::InvalidToolParams(_)));
}
#[test]
fn test_client_for_file_uses_custom_extension() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("script.nu");
fs::write(&test_file, "echo hello").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert(
FileExtension::from_static("nu"),
LanguageId::from_static("nushell"),
);
let translator = Translator::new().with_extensions(extension_map);
let result = translator.client_for_file(&test_file, ToolKind::Hover);
assert!(result.is_err());
if let Err(Error::NoServerForLanguage { language, file, .. }) = result {
assert_eq!(language, "nushell");
assert_eq!(file, FileKey::Extension(FileExtension::from_static("nu")));
} else {
panic!("Expected NoServerForLanguage(nushell) error");
}
}
#[test]
fn test_client_for_file_reports_an_extensionless_name() {
let mut language_map = LanguageMap::default();
language_map.insert(
crate::config::PatternTarget::Name(crate::config::FileName::from_static("Makefile")),
LanguageId::from_static("make"),
);
let translator = Translator::new().with_extensions(language_map);
let result = translator.client_for_file(Path::new("/ws/Makefile"), ToolKind::Hover);
assert_matches!(
result,
Err(Error::NoServerForLanguage { language, .. }) if language == "make"
);
let result = translator.client_for_file(Path::new("/ws/Dockerfile"), ToolKind::Hover);
assert_matches!(
result,
Err(Error::NoServerForLanguage { file: FileKey::Name(name), .. }) if name == "Dockerfile"
);
}
#[test]
fn test_client_for_file_falls_back_to_default() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("unknown.xyz");
fs::write(&test_file, "content").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert(
FileExtension::from_static("rs"),
LanguageId::from_static("rust"),
);
let translator = Translator::new()
.with_extensions(extension_map)
.with_file_patterns([FilePattern::from_static("**/*.rs")]);
let result = translator.client_for_file(&test_file, ToolKind::Hover);
assert!(result.is_err());
if let Err(Error::NoServerForLanguage {
language,
file,
patterns,
}) = result
{
assert_eq!(language, "plaintext");
assert_eq!(file, FileKey::Extension(FileExtension::from_static("xyz")));
assert_eq!(&*patterns, [FilePattern::from_static("**/*.rs")]);
} else {
panic!("Expected NoServerForLanguage(plaintext) error");
}
}
#[test]
fn test_client_for_file_routes_tsx_to_typescript_server() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("component.tsx");
fs::write(&test_file, "export const Component = () => <div />").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert(
FileExtension::from_static("tsx"),
LanguageId::from_static("typescriptreact"),
);
let translator = Translator::new()
.with_extensions(extension_map)
.with_router(ToolRouter::catch_all([(
ServerId::from_static("typescript"),
LanguageId::from_static("typescript"),
)]));
translator.register_client(
ServerId::from_static("typescript"),
LspClient::new(crate::config::LspServerConfig::typescript()),
);
let (_id, client) = translator
.client_for_file(&test_file, ToolKind::Hover)
.unwrap();
assert_eq!(client.language_id(), "typescript");
}
#[test]
fn test_client_for_file_prefers_exact_react_server() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("component.tsx");
fs::write(&test_file, "export const Component = () => <div />").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert(
FileExtension::from_static("tsx"),
LanguageId::from_static("typescriptreact"),
);
let typescript_react_config = crate::config::LspServerConfig {
language_id: LanguageId::from_static("typescriptreact"),
command: ServerCommand::from_static("typescript-language-server").into(),
args: crate::config::ServerArgs::try_from(vec!["--stdio".to_string()]).unwrap(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![FilePattern::from_static("**/*.tsx")],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
};
let translator = Translator::new()
.with_extensions(extension_map)
.with_router(ToolRouter::catch_all([
(
ServerId::from_static("typescript"),
LanguageId::from_static("typescript"),
),
(
ServerId::from_static("typescriptreact"),
LanguageId::from_static("typescriptreact"),
),
]));
translator.register_client(
ServerId::from_static("typescript"),
LspClient::new(crate::config::LspServerConfig::typescript()),
);
translator.register_client(
ServerId::from(LanguageId::from_static("typescriptreact")),
LspClient::new(typescript_react_config),
);
let (_id, client) = translator
.client_for_file(&test_file, ToolKind::Hover)
.unwrap();
assert_eq!(client.language_id(), "typescriptreact");
}
#[test]
fn test_diagnostics_route_for_path_live_when_registered() {
let id = ServerId::from_static("rust");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("rust"),
)]));
translator.register_client(
id.clone(),
LspClient::new(crate::config::LspServerConfig::rust_analyzer()),
);
let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
assert_matches!(&route, DiagnosticsRoute::Live(live) if *live == id,
"{route:?}"
);
assert_eq!(route.server_id(), Some(&id));
assert_eq!(route.into_read_result().unwrap(), Some(id));
}
#[test]
fn test_diagnostics_route_for_path_initializing_while_expected() {
let id = ServerId::from_static("rust");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("rust"),
)]));
translator.set_expected_servers(HashSet::from([id.clone()]));
let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
assert_matches!(&route, DiagnosticsRoute::Initializing(i) if *i == id,
"{route:?}"
);
assert_eq!(route.server_id(), Some(&id));
let err = route.into_read_result().unwrap_err();
assert_matches!(&err, Error::ServerInitializing { server_id } if *server_id == id,
"{err:?}"
);
}
#[test]
fn test_diagnostics_route_for_path_failed_to_start() {
let id = ServerId::from_static("rust");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("rust"),
)]));
translator.record_startup_failures(&[not_found_failure(&id, "rust", "rust-analyzer")]);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
assert!(route.is_failed(), "{route:?}");
assert_eq!(route.server_id(), None);
let err = route.into_read_result().unwrap_err();
assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == id,
"{err:?}"
);
}
#[test]
fn test_diagnostics_route_for_path_unrouted_reads_empty() {
let id = ServerId::from_static("rust");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("rust"),
)]));
translator.record_startup_failures(&[not_found_failure(&id, "rust", "rust-analyzer")]);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let route = translator.diagnostics_route_for_path(Path::new("/ws/script.py"));
assert_matches!(route, DiagnosticsRoute::Unrouted, "{route:?}");
assert_eq!(route.into_read_result().unwrap(), None);
}
#[test]
fn test_diagnostics_route_for_path_dangling_is_unrouted() {
let id = ServerId::from_static("rust");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id,
LanguageId::from_static("rust"),
)]));
let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
assert_matches!(route, DiagnosticsRoute::Unrouted, "{route:?}");
}
#[tokio::test]
async fn test_diagnostics_route_for_path_live_catch_all_wins_over_failed_explicit_server() {
let configs = [
router_config("rust", "diag-only", Some(vec![ToolKind::Diagnostics])),
router_config("rust", "catch-all", None),
];
let router = ToolRouter::from_configs(configs.iter()).unwrap();
let failed_id = ServerId::from_static("diag-only");
let live_id = ServerId::from_static("catch-all");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(router);
let (client, _server) = fake_lsp_client();
translator.register_client(live_id.clone(), client);
translator.record_startup_failures(&[not_found_failure(&failed_id, "rust", "sh")]);
translator.rebind_router(&HashSet::from([live_id.clone()]));
translator.clear_expected_servers();
let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
assert_matches!(&route, DiagnosticsRoute::Live(id) if *id == live_id,
"{route:?}"
);
}
#[test]
fn test_diagnostics_route_for_path_reports_failure_through_react_base_language() {
let id = ServerId::from_static("typescript");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("typescript"),
)]));
translator.record_startup_failures(&[not_found_failure(
&id,
"typescript",
"typescript-language-server",
)]);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let route = translator.diagnostics_route_for_path(Path::new("/ws/app.tsx"));
assert_matches!(&route, DiagnosticsRoute::FailedToStart(f) if f.server_id == id,
"{route:?}"
);
}
#[test]
fn test_diagnostics_route_for_path_ignores_failure_of_server_without_diagnostics_route() {
let configs = [router_config(
"rust",
"hover-only",
Some(vec![ToolKind::Hover]),
)];
let router = ToolRouter::from_configs(configs.iter()).unwrap();
let failed = ServerId::from_static("hover-only");
let translator = Translator::new()
.with_extensions(test_extensions())
.with_router(router);
translator.record_startup_failures(&[not_found_failure(&failed, "rust", "sh")]);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let route = translator.diagnostics_route_for_path(Path::new("/ws/main.rs"));
assert_matches!(route, DiagnosticsRoute::Unrouted, "{route:?}");
}
#[tokio::test]
async fn test_handle_diagnostics_reports_failed_server_start() {
let dir = TempDir::new().unwrap();
let file = dunce::canonicalize(dir.path()).unwrap().join("main.rs");
fs::write(&file, "fn main() {}").unwrap();
let id = ServerId::from_static("rust");
let mut translator = Translator::new()
.with_extensions(test_extensions())
.with_router(ToolRouter::catch_all([(
id.clone(),
LanguageId::from_static("rust"),
)]));
translator
.set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
translator.record_startup_failures(&[not_found_failure(&id, "rust", "sh")]);
translator.rebind_router(&HashSet::new());
translator.clear_expected_servers();
let cache = Mutex::new(crate::bridge::NotificationCache::new());
let err = translator
.handle_diagnostics(
client_path(file.to_string_lossy().into_owned()),
ResultContext::None,
&cache,
)
.await
.unwrap_err();
assert_matches!(&err, Error::ServerFailedToStart(f) if f.server_id == id,
"got {err:?}"
);
}
#[cfg(unix)]
struct AliasFixture {
dir: TempDir,
real: PathBuf,
alias: PathBuf,
roots: WorkspaceRoots,
}
#[cfg(unix)]
fn alias_fixture() -> AliasFixture {
let dir = TempDir::new().unwrap();
let real = dir.path().join("real");
fs::create_dir(&real).unwrap();
let alias = dir.path().join("alias");
std::os::unix::fs::symlink(&real, &alias).unwrap();
let roots = WorkspaceRoots::from_paths(std::slice::from_ref(&alias)).unwrap();
AliasFixture {
dir,
real,
alias,
roots,
}
}
#[test]
fn test_client_for_file_routes_jsx_to_javascript_server() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("component.jsx");
fs::write(&test_file, "export const Component = () => <div />").unwrap();
let mut extension_map = HashMap::new();
extension_map.insert(
FileExtension::from_static("jsx"),
LanguageId::from_static("javascriptreact"),
);
let javascript_config = crate::config::LspServerConfig {
language_id: LanguageId::from_static("javascript"),
command: ServerCommand::from_static("typescript-language-server").into(),
args: crate::config::ServerArgs::try_from(vec!["--stdio".to_string()]).unwrap(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![
FilePattern::from_static("**/*.js"),
FilePattern::from_static("**/*.jsx"),
],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: None,
handles: None,
indexing: crate::bridge::IndexingPolicy::Auto,
};
let translator = Translator::new()
.with_extensions(extension_map)
.with_router(ToolRouter::catch_all([(
ServerId::from_static("javascript"),
LanguageId::from_static("javascript"),
)]));
translator.register_client(
ServerId::from_static("javascript"),
LspClient::new(javascript_config),
);
let (_id, client) = translator
.client_for_file(&test_file, ToolKind::Hover)
.unwrap();
assert_eq!(client.language_id(), "javascript");
}
#[tokio::test]
async fn test_serve_initializes_translator_with_extensions() {
use crate::config::{LanguageExtensionMapping, WorkspaceConfig};
let language_extensions = vec![
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("nu")],
language_id: LanguageId::from_static("nushell"),
},
LanguageExtensionMapping {
extensions: vec![FileExtension::from_static("rs")],
language_id: LanguageId::from_static("rust"),
},
];
let config = crate::config::ServerConfig {
mcp: crate::config::McpConfig::default(),
workspace: WorkspaceConfig {
roots: vec![crate::config::ConfiguredRoot::new("/tmp/test-workspace").unwrap()],
position_encodings: PositionEncodings::DEFAULT,
language_extensions: language_extensions.clone(),
heuristics_max_depth: SearchDepth::DEFAULT,
max_documents: DocumentLimit::DEFAULT,
max_file_size: SizeLimit::DEFAULT,
indexing_ready_timeout_seconds: IndexingReadyTimeoutSecs::DEFAULT,
max_concurrent_server_starts: crate::config::ServerStartConcurrency::DEFAULT,
},
lsp_servers: vec![],
project_config_status: crate::ProjectConfigStatus::NotIgnored,
workspace_trust: crate::config::WorkspaceTrust::default(),
};
let language_map = config.build_effective_language_map();
assert_eq!(language_map.detect(Path::new("a.nu")), "nushell");
assert_eq!(language_map.detect(Path::new("a.rs")), "rust");
let result = crate::serve(config).await;
if let Err(ref err) = result {
assert!(
!matches!(err, crate::error::Error::AllServersFailedToInit { .. }),
"serve() must not report init failures for empty lsp_servers config"
);
}
}
#[tokio::test]
async fn test_concurrent_handlers_on_different_files_do_not_serialize() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert(
FileExtension::from_static("aa"),
LanguageId::from_static("lang_a"),
);
extensions.insert(
FileExtension::from_static("bb"),
LanguageId::from_static("lang_b"),
);
let mut translator =
Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([
(
ServerId::from_static("lang_a"),
LanguageId::from_static("lang_a"),
),
(
ServerId::from_static("lang_b"),
LanguageId::from_static("lang_b"),
),
]));
translator
.set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
let (client_a, mut server_a) = fake_lsp_client();
let (client_b, mut server_b) = fake_lsp_client();
translator.register_client(ServerId::from_static("lang_a"), client_a);
translator.register_client(ServerId::from_static("lang_b"), client_b);
let path_a = dir.path().join("file.aa");
let path_b = dir.path().join("file.bb");
fs::write(&path_a, "content a").unwrap();
fs::write(&path_b, "content b").unwrap();
let translator = Arc::new(translator);
let slow = {
let translator = Arc::clone(&translator);
let path = path_a.to_string_lossy().to_string();
tokio::spawn(async move {
translator
.handle_hover(client_path(path), Position::at(1, 1))
.await
})
};
let mut wire_a = BufReader::new(&mut server_a.write_stdout);
let opened_a = read_framed_message(&mut wire_a).await;
assert_eq!(opened_a["method"], "textDocument/didOpen");
let hover_request_a = read_framed_message(&mut wire_a).await;
assert_eq!(hover_request_a["method"], "textDocument/hover");
let fast = {
let translator = Arc::clone(&translator);
let path = path_b.to_string_lossy().to_string();
tokio::spawn(async move {
translator
.handle_hover(client_path(path), Position::at(1, 1))
.await
})
};
let mut wire_b = BufReader::new(&mut server_b.write_stdout);
let opened_b = read_framed_message(&mut wire_b).await;
assert_eq!(opened_b["method"], "textDocument/didOpen");
let hover_request_b = read_framed_message(&mut wire_b).await;
assert_eq!(hover_request_b["method"], "textDocument/hover");
write_response(
&mut server_b.read_half_stdin,
&hover_request_b["id"],
JsonValue::Null,
)
.await;
let fast_result = timeout(Duration::from_secs(2), fast)
.await
.expect("fast call must not be blocked by the slow in-flight request")
.unwrap();
assert!(fast_result.is_ok());
assert!(
!slow.is_finished(),
"slow call should still be waiting on its (never-sent) response"
);
slow.abort();
}
#[tokio::test]
async fn test_concurrent_ensure_open_same_path_sends_single_did_open() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert(
FileExtension::from_static("aa"),
LanguageId::from_static("lang_a"),
);
let mut translator =
Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([(
ServerId::from_static("lang_a"),
LanguageId::from_static("lang_a"),
)]));
translator
.set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
let (client, mut server) = fake_lsp_client();
translator.register_client(ServerId::from_static("lang_a"), client);
let path = dir.path().join("file.aa");
fs::write(&path, "content").unwrap();
let concurrent_calls = 4;
let translator = Arc::new(translator);
let path_str = path.to_string_lossy().to_string();
let handles: Vec<_> = (0..concurrent_calls)
.map(|_| {
let translator = Arc::clone(&translator);
let path_str = path_str.clone();
tokio::spawn(async move {
translator
.handle_hover(client_path(path_str), Position::at(1, 1))
.await
})
})
.collect();
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
for _ in 0..concurrent_calls {
let request = read_framed_message(&mut wire).await;
assert_eq!(
request["method"], "textDocument/hover",
"no second didOpen must appear ahead of the hover requests"
);
write_response(&mut server.read_half_stdin, &request["id"], JsonValue::Null).await;
}
for handle in handles {
let result = timeout(Duration::from_secs(2), handle)
.await
.expect("handler call should not hang")
.unwrap();
assert!(result.is_ok());
}
}
fn single_document_translator(dir: &TempDir) -> (Translator, crate::test_lsp::FakeServer) {
use crate::bridge::state::ResourceLimits;
let mut extensions = HashMap::new();
extensions.insert(
FileExtension::from_static("aa"),
LanguageId::from_static("lang_a"),
);
let mut translator = Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([(
ServerId::from_static("lang_a"),
LanguageId::from_static("lang_a"),
)]))
.with_resource_limits(ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::UNLIMITED,
});
translator
.set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
let (client, server) = fake_lsp_client();
translator.register_client(ServerId::from_static("lang_a"), client);
(translator, server)
}
#[tokio::test]
async fn test_flush_pending_closes_drops_failed_servers_debt() {
let dir = TempDir::new().unwrap();
let (translator, mut server_a) = single_document_translator(&dir);
let (client_d, _server_d) = fake_lsp_client();
let tracker = &translator.document_tracker;
let path = dir.path().join("p.aa");
std::fs::write(&path, "p").unwrap();
let other = dir.path().join("q.aa");
std::fs::write(&other, "q").unwrap();
let client_a = lock_std(&translator.servers)
.client(&ServerId::from_static("lang_a"))
.unwrap();
tracker
.ensure_open_for_test(&path, &ServerId::from_static("lang_a"), &client_a)
.await
.unwrap();
tracker
.ensure_open_for_test(&path, &ServerId::from_static("lang_d"), &client_d)
.await
.unwrap();
tracker
.ensure_open_for_test(&other, &ServerId::from_static("lang_a"), &client_a)
.await
.unwrap();
translator.register_client(ServerId::from_static("lang_d"), client_d.clone());
client_d.shutdown().await.unwrap();
translator.flush_pending_closes().await;
let mut wire = tokio::io::BufReader::new(&mut server_a.write_stdout);
for method in [
"textDocument/didOpen",
"textDocument/didOpen",
"textDocument/didClose",
] {
assert_eq!(read_framed_message(&mut wire).await["method"], method);
}
assert!(
tracker.pending_close_paths().is_empty(),
"a failed close is dropped, not re-owed"
);
}
#[tokio::test(start_paused = true)]
async fn test_flush_pending_closes_dropped_midway_keeps_undelivered_closes_owed() {
let dir = TempDir::new().unwrap();
let (translator, mut server_a) = single_document_translator(&dir);
let (client_d, _probe_d) =
LspClient::with_command_channel(crate::config::LspServerConfig::rust_analyzer(), 2);
let (a, d) = (
ServerId::from_static("lang_a"),
ServerId::from_static("lang_d"),
);
let tracker = &translator.document_tracker;
let path = dir.path().join("p.aa");
std::fs::write(&path, "p").unwrap();
let other = dir.path().join("q.aa");
std::fs::write(&other, "q").unwrap();
let client_a = lock_std(&translator.servers).client(&a).unwrap();
tracker
.ensure_open_for_test(&path, &a, &client_a)
.await
.unwrap();
tracker
.ensure_open_for_test(&path, &d, &client_d)
.await
.unwrap();
tracker
.ensure_open_for_test(&other, &a, &client_a)
.await
.unwrap();
translator.register_client(d.clone(), client_d.clone());
client_d
.notify("test/fill", serde_json::json!({}))
.await
.unwrap();
{
let flush = translator.flush_pending_closes();
let mut flush = std::pin::pin!(flush);
assert!(futures::poll!(flush.as_mut()).is_pending());
}
let mut wire = tokio::io::BufReader::with_capacity(1, &mut server_a.write_stdout);
for method in ["textDocument/didOpen", "textDocument/didOpen"] {
assert_eq!(read_framed_message(&mut wire).await["method"], method);
}
let a_close_delivered =
tokio::time::timeout(Duration::from_millis(100), read_framed_message(&mut wire))
.await
.is_ok();
let owed: Vec<ServerId> = tracker
.try_claim_pending_close(&path)
.unwrap()
.servers()
.cloned()
.collect();
assert!(
owed.contains(&d),
"the close nobody delivered must stay owed"
);
assert_eq!(
owed.contains(&a),
!a_close_delivered,
"a close is owed exactly when it was not delivered: {owed:?}"
);
}
#[tokio::test]
async fn test_prepare_document_sends_didclose_for_evicted_document() {
let dir = TempDir::new().unwrap();
let (translator, mut server) = single_document_translator(&dir);
let path_a = dir.path().join("a.aa");
fs::write(&path_a, "content a").unwrap();
let path_b = dir.path().join("b.aa");
fs::write(&path_b, "content b").unwrap();
drop(
translator
.prepare_document(
&client_path(path_a.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap(),
);
let mut wire = BufReader::new(&mut server.write_stdout);
let opened_a = read_framed_message(&mut wire).await;
assert_eq!(opened_a["method"], "textDocument/didOpen");
drop(
translator
.prepare_document(
&client_path(path_b.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap(),
);
let opened_b = read_framed_message(&mut wire).await;
assert_eq!(opened_b["method"], "textDocument/didOpen");
let closed_a = read_framed_message(&mut wire).await;
assert_eq!(
closed_a["method"], "textDocument/didClose",
"evicting `a` to make room for `b` under max_documents: 1 must notify its server"
);
assert_eq!(
closed_a["params"]["textDocument"]["uri"], opened_a["params"]["textDocument"]["uri"],
"the didClose must name the evicted document, not the newly opened one"
);
}
#[tokio::test]
async fn test_prepared_document_blocks_eviction_until_dropped() {
let dir = TempDir::new().unwrap();
let (translator, _server) = single_document_translator(&dir);
let path_a = dir.path().join("a.aa");
fs::write(&path_a, "content a").unwrap();
let path_b = dir.path().join("b.aa");
fs::write(&path_b, "content b").unwrap();
let doc_a = translator
.prepare_document(
&client_path(path_a.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap();
let err = translator
.prepare_document(
&client_path(path_b.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap_err();
assert_matches!(err, Error::DocumentLimitExceeded { .. });
drop(doc_a);
drop(
translator
.prepare_document(
&client_path(path_b.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap(),
);
}
#[tokio::test]
async fn test_two_prepared_documents_on_same_path_are_refcounted() {
let dir = TempDir::new().unwrap();
let (translator, _server) = single_document_translator(&dir);
let path_a = dir.path().join("a.aa");
fs::write(&path_a, "content a").unwrap();
let path_b = dir.path().join("b.aa");
fs::write(&path_b, "content b").unwrap();
let canonical_a = dunce::canonicalize(&path_a).unwrap();
let first = translator
.prepare_document(
&client_path(path_a.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap();
let second = translator
.prepare_document(
&client_path(path_a.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap();
assert_eq!(translator.document_tracker.in_flight_count(&canonical_a), 2);
drop(first);
let err = translator
.prepare_document(
&client_path(path_b.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap_err();
assert_matches!(err, Error::DocumentLimitExceeded { .. });
drop(second);
assert_eq!(translator.document_tracker.in_flight_count(&canonical_a), 0);
drop(
translator
.prepare_document(
&client_path(path_b.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap(),
);
}
#[tokio::test]
async fn test_prepare_document_releases_in_flight_guard_when_ensure_open_fails() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert(
FileExtension::from_static("bb"),
LanguageId::from_static("lang_b"),
);
let mut translator =
Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([(
ServerId::from_static("lang_b"),
LanguageId::from_static("lang_b"),
)]));
translator
.set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
let (client, _server) = fake_lsp_client();
translator.register_client(ServerId::from_static("lang_b"), client.clone());
let path_b = dir.path().join("b.bb");
fs::write(&path_b, "content b").unwrap();
let canonical_b = dunce::canonicalize(&path_b).unwrap();
client.shutdown().await.unwrap();
let err = translator
.prepare_document(
&client_path(path_b.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap_err();
assert_matches!(err, Error::ServerTerminated);
assert_eq!(translator.document_tracker.in_flight_count(&canonical_b), 0);
}
#[tokio::test]
async fn test_prepare_document_still_sends_didclose_when_ensure_open_itself_fails() {
use crate::bridge::state::ResourceLimits;
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert(
FileExtension::from_static("aa"),
LanguageId::from_static("lang_a"),
);
extensions.insert(
FileExtension::from_static("bb"),
LanguageId::from_static("lang_b"),
);
let mut translator = Translator::new()
.with_extensions(extensions)
.with_router(ToolRouter::catch_all([
(
ServerId::from_static("lang_a"),
LanguageId::from_static("lang_a"),
),
(
ServerId::from_static("lang_b"),
LanguageId::from_static("lang_b"),
),
]))
.with_resource_limits(ResourceLimits {
max_documents: DocumentLimit::new(1),
max_file_size: SizeLimit::UNLIMITED,
});
translator
.set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
let (client_a, mut server_a) = fake_lsp_client();
translator.register_client(ServerId::from_static("lang_a"), client_a);
let (client_b, _server_b) = fake_lsp_client();
translator.register_client(ServerId::from_static("lang_b"), client_b.clone());
let path_a = dir.path().join("a.aa");
fs::write(&path_a, "content a").unwrap();
let path_b = dir.path().join("b.bb");
fs::write(&path_b, "content b").unwrap();
drop(
translator
.prepare_document(
&client_path(path_a.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap(),
);
let mut wire_a = BufReader::new(&mut server_a.write_stdout);
let opened_a = read_framed_message(&mut wire_a).await;
assert_eq!(opened_a["method"], "textDocument/didOpen");
client_b.shutdown().await.unwrap();
let err = translator
.prepare_document(
&client_path(path_b.to_string_lossy().into_owned()),
ToolKind::Hover,
)
.await
.unwrap_err();
assert_matches!(err, Error::ServerTerminated);
let closed_a = read_framed_message(&mut wire_a).await;
assert_eq!(closed_a["method"], "textDocument/didClose");
assert_eq!(
closed_a["params"]["textDocument"]["uri"],
opened_a["params"]["textDocument"]["uri"]
);
}
#[tokio::test]
async fn test_dispatch_routes_hover_and_diagnostics_to_different_servers() {
let dir = TempDir::new().unwrap();
let mut extensions = HashMap::new();
extensions.insert(
FileExtension::from_static("py"),
LanguageId::from_static("python"),
);
let pyright_id = ServerId::from_static("pyright");
let pylsp_id = ServerId::from_static("pylsp");
let pyright = LspServerConfig {
language_id: LanguageId::from_static("python"),
command: ServerCommand::from_static("pyright-langserver").into(),
args: crate::config::ServerArgs::default(),
env: crate::config::ServerEnv::default(),
file_patterns: vec![],
initialization_options: None,
settings: None,
timeout_seconds: TimeoutSecs::new(30).unwrap(),
request_timeout_seconds: TimeoutSecs::new(30).unwrap(),
heuristics: None,
name: Some(ServerId::from_static("pyright")),
handles: Some(ToolSet::single(ToolKind::Hover)),
indexing: crate::bridge::IndexingPolicy::Auto,
};
let pylsp = LspServerConfig {
command: ServerCommand::from_static("pylsp").into(),
name: Some(ServerId::from_static("pylsp")),
handles: Some(ToolSet::single(ToolKind::Diagnostics)),
..pyright.clone()
};
let configs = vec![pyright, pylsp];
let router = ToolRouter::from_configs(&configs).unwrap();
let mut translator = Translator::new()
.with_extensions(extensions)
.with_router(router);
translator
.set_workspace_roots(WorkspaceRoots::from_paths(&[dir.path().to_path_buf()]).unwrap());
let (client_pyright, mut server_pyright) = fake_lsp_client();
let (client_pylsp, mut server_pylsp) = fake_lsp_client();
translator.register_client(pyright_id, client_pyright);
translator.register_client(pylsp_id, client_pylsp);
let path = dir.path().join("main.py");
fs::write(&path, "x = 1").unwrap();
let file = client_path(&path);
let translator = Arc::new(translator);
let rename_result = translator
.handle_rename(
file.clone(),
pos(1, 1),
crate::bridge::NewName::new("renamed").unwrap(),
)
.await;
assert_matches!(
rename_result,
Err(Error::NoServerForTool {
tool: ToolKind::Rename,
..
}),
"expected NoServerForTool for rename, got {rename_result:?}"
);
let hover = {
let translator = Arc::clone(&translator);
let file = file.clone();
tokio::spawn(async move { translator.handle_hover(file, pos(1, 1)).await })
};
let mut wire_pyright = BufReader::new(&mut server_pyright.write_stdout);
let opened = read_framed_message(&mut wire_pyright).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let hover_request = read_framed_message(&mut wire_pyright).await;
assert_eq!(hover_request["method"], "textDocument/hover");
write_response(
&mut server_pyright.read_half_stdin,
&hover_request["id"],
JsonValue::Null,
)
.await;
hover
.await
.unwrap()
.expect("hover routed to pyright must succeed");
let diagnostics = {
let translator = Arc::clone(&translator);
let notification_cache = Arc::new(Mutex::new(NotificationCache::new()));
tokio::spawn(async move {
translator
.handle_diagnostics(file, ResultContext::None, ¬ification_cache)
.await
})
};
let mut wire_pylsp = BufReader::new(&mut server_pylsp.write_stdout);
let opened = read_framed_message(&mut wire_pylsp).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let diag_request = read_framed_message(&mut wire_pylsp).await;
assert_eq!(diag_request["method"], "textDocument/diagnostic");
diagnostics.abort();
}
#[test]
fn test_require_capability_ok_when_server_not_registered() {
let translator = Translator::new();
let result =
translator.require_capability(&ServerId::from_static("rust"), Capability::Rename);
assert!(result.is_ok());
}
#[tokio::test]
async fn test_require_capability_ok_when_capability_present() {
let translator = Translator::new();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities {
rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
..Default::default()
};
translator.register_server(server_id.clone(), LspServer::new_for_test(caps));
let result = translator.require_capability(&server_id, Capability::Rename);
assert!(result.is_ok());
}
#[tokio::test]
async fn test_require_capability_err_when_capability_absent() {
let translator = Translator::new();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities::default();
translator.register_server(server_id.clone(), LspServer::new_for_test(caps));
let result = translator.require_capability(&server_id, Capability::Rename);
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::Rename,
..
})
);
}
#[tokio::test]
async fn test_handle_rename_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_rename(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
crate::bridge::NewName::new("renamed").unwrap(),
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::Rename,
..
})
);
}
#[tokio::test]
async fn test_handle_code_actions_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_code_actions(
client_path(path.to_string_lossy().into_owned()),
bounded(Position::at(1, 1), Position::at(1, 5)),
None,
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::CodeActions,
..
})
);
}
#[tokio::test]
async fn test_handle_signature_help_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_signature_help(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::SignatureHelp,
..
})
);
}
#[tokio::test]
async fn test_handle_incoming_calls_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let uri = Url::from_file_path(&path).unwrap().to_string();
let item = serde_json::json!({
"name": "test_function",
"kind": 12,
"uri": uri,
"range": {
"start": {"line": 1, "character": 1},
"end": {"line": 1, "character": 10}
},
"selectionRange": {
"start": {"line": 1, "character": 1},
"end": {"line": 1, "character": 10}
}
});
let result = translator
.handle_incoming_calls(checked_hierarchy_item(item))
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::CallHierarchy,
..
})
);
}
#[tokio::test]
async fn test_handle_outgoing_calls_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let uri = Url::from_file_path(&path).unwrap().to_string();
let item = serde_json::json!({
"name": "test_function",
"kind": 12,
"uri": uri,
"range": {
"start": {"line": 1, "character": 1},
"end": {"line": 1, "character": 10}
},
"selectionRange": {
"start": {"line": 1, "character": 1},
"end": {"line": 1, "character": 10}
}
});
let result = translator
.handle_outgoing_calls(checked_hierarchy_item(item))
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::CallHierarchy,
..
})
);
}
#[tokio::test]
async fn test_handle_format_document_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_format_document(
client_path(path.to_string_lossy().into_owned()),
crate::bridge::TabSize::default(),
true,
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::FormatDocument,
..
})
);
}
#[tokio::test]
async fn test_handle_call_hierarchy_prepare_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_call_hierarchy_prepare(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::CallHierarchy,
..
})
);
}
#[tokio::test]
async fn test_handle_inlay_hints_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_inlay_hints(
client_path(path.to_string_lossy().into_owned()),
span(Position::at(1, 1), Position::at(10, 1)),
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::InlayHints,
..
})
);
}
#[tokio::test]
async fn test_handle_hover_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_hover(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::Hover,
..
})
);
}
#[tokio::test]
async fn test_handle_definition_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_definition(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
ResultContext::None,
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::Definition,
..
})
);
}
#[tokio::test]
async fn test_handle_references_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_references(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
false,
ResultContext::None,
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::References,
..
})
);
}
#[tokio::test]
async fn test_handle_completions_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_completions(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
None,
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::Completions,
..
})
);
}
#[tokio::test]
async fn test_handle_document_symbols_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_document_symbols(client_path(path.to_string_lossy().into_owned()))
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::DocumentSymbols,
..
})
);
}
#[tokio::test]
async fn test_handle_workspace_symbol_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let result = translator
.handle_workspace_symbol("main".to_string(), None, 100)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::WorkspaceSymbols,
..
})
);
}
#[tokio::test]
async fn test_handle_implementation_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_implementation(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
ResultContext::None,
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::Implementation,
..
})
);
}
#[tokio::test]
async fn test_handle_type_definition_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_type_definition(
client_path(path.to_string_lossy().into_owned()),
Position::at(1, 1),
ResultContext::None,
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::TypeDefinition,
..
})
);
}
#[tokio::test]
async fn test_handle_declaration_blocked_when_capability_not_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let (translator, _server) = translator_with_capabilities(
&dir,
&server_id,
lsp_types::ServerCapabilities::default(),
);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let result = translator
.handle_declaration(
client_path(&path),
Position::at(1, 1),
crate::bridge::ResultContext::None,
)
.await;
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::Declaration,
..
})
);
}
#[tokio::test]
async fn test_require_capability_err_when_capability_explicitly_false() {
let translator = Translator::new();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities {
rename_provider: Some(lsp_types::RenameProvider::Bool(false)),
..Default::default()
};
translator.register_server(server_id.clone(), LspServer::new_for_test(caps));
let result = translator.require_capability(&server_id, Capability::Rename);
assert_matches!(
result,
Err(Error::CapabilityNotSupported {
capability: Capability::Rename,
..
})
);
}
#[tokio::test]
async fn test_handle_rename_proceeds_when_capability_supported() {
let dir = TempDir::new().unwrap();
let server_id = ServerId::from_static("rust");
let caps = lsp_types::ServerCapabilities {
rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
..Default::default()
};
let (translator, mut server) = translator_with_capabilities(&dir, &server_id, caps);
let path = dir.path().join("main.rs");
fs::write(&path, "fn main() {}").unwrap();
let path_str = path.to_string_lossy().to_string();
let translator = Arc::new(translator);
let handle = {
let translator = Arc::clone(&translator);
tokio::spawn(async move {
translator
.handle_rename(
client_path(path_str),
Position::at(1, 1),
crate::bridge::NewName::new("renamed").unwrap(),
)
.await
})
};
let mut wire = BufReader::new(&mut server.write_stdout);
let opened = read_framed_message(&mut wire).await;
assert_eq!(opened["method"], "textDocument/didOpen");
let rename_request = read_framed_message(&mut wire).await;
assert_eq!(rename_request["method"], "textDocument/rename");
write_response(
&mut server.read_half_stdin,
&rename_request["id"],
JsonValue::Null,
)
.await;
let result = timeout(Duration::from_secs(2), handle)
.await
.expect("handler call should not hang")
.unwrap();
assert!(
!matches!(result, Err(Error::CapabilityNotSupported { .. })),
"capability is supported, gate must not block dispatch, got {result:?}"
);
assert!(
result.is_ok(),
"fake server answered, expected Ok: {result:?}"
);
}
fn assert_gates_on_provider_forms<P>(
supported: impl Fn(Option<P>) -> bool,
forms: impl IntoIterator<Item = P>,
disabled: P,
) {
for form in forms {
assert!(supported(Some(form)));
}
assert!(!supported(Some(disabled)));
assert!(!supported(None));
}
#[test]
fn selection_range_capability_gates_on_every_provider_form() {
use lsp_types::SelectionRangeProvider as Provider;
assert_gates_on_provider_forms(
|provider| {
Capability::SelectionRange.is_supported(&lsp_types::ServerCapabilities {
selection_range_provider: provider,
..Default::default()
})
},
[
Provider::Bool(true),
Provider::SelectionRangeOptions(lsp_types::SelectionRangeOptions::default()),
Provider::SelectionRangeRegistrationOptions(
lsp_types::SelectionRangeRegistrationOptions::default(),
),
],
Provider::Bool(false),
);
}
#[test]
fn folding_range_capability_gates_on_every_provider_form() {
use lsp_types::FoldingRangeProvider as Provider;
assert_gates_on_provider_forms(
|provider| {
Capability::FoldingRange.is_supported(&lsp_types::ServerCapabilities {
folding_range_provider: provider,
..Default::default()
})
},
[
Provider::Bool(true),
Provider::FoldingRangeOptions(lsp_types::FoldingRangeOptions::default()),
Provider::FoldingRangeRegistrationOptions(
lsp_types::FoldingRangeRegistrationOptions::default(),
),
],
Provider::Bool(false),
);
}
}