Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
[package]
|
||||
name = "lsp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Warp Team <dev@warp.dev>"]
|
||||
publish.workspace = true
|
||||
license.workspace = true
|
||||
description = "Language Server Protocol implementation for Warp"
|
||||
|
||||
[features]
|
||||
local_fs = []
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
async-channel = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
globset = { workspace = true }
|
||||
itertools.workspace = true
|
||||
jsonrpc.workspace = true
|
||||
log = { workspace = true }
|
||||
lsp-types = "0.97.0"
|
||||
node_runtime = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
strum_macros = { workspace = true }
|
||||
warp_core = { workspace = true }
|
||||
warp_util.workspace = true
|
||||
warpui.workspace = true
|
||||
instant.workspace = true
|
||||
http_client = { workspace = true }
|
||||
cfg-if.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
async-fs = { workspace = true }
|
||||
url = "2"
|
||||
async-process = { workspace = true }
|
||||
command.workspace = true
|
||||
simple_logger.workspace = true
|
||||
nix = { workspace = true }
|
||||
repo_metadata.workspace = true
|
||||
sha2 = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process"] }
|
||||
@@ -0,0 +1,9 @@
|
||||
# lsp
|
||||
|
||||
This crate provides a stdio-only Language Server Protocol (LSP) client transport for Warp. It:
|
||||
|
||||
- Spawns and manages a language server process (child process)
|
||||
- Communicates over stdio using JSON-RPC with proper Content-Length framing
|
||||
|
||||
|
||||
See main.rs for an example implmentation
|
||||
@@ -0,0 +1,11 @@
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let target_family = std::env::var("CARGO_CFG_TARGET_FAMILY")?;
|
||||
|
||||
if target_family != "wasm" {
|
||||
println!("cargo:rustc-cfg=feature=\"local_fs\"");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! LSP crate demonstration showing proper usage of LspService and core LSP functionality.
|
||||
//!
|
||||
//! This demo showcases:
|
||||
//! - LSP server initialization using rust-analyzer
|
||||
//! - Document lifecycle management (open/close)
|
||||
//! - Core LSP features: go-to-definition, hover, completion, symbols
|
||||
//! - Proper shutdown and error handling
|
||||
|
||||
use std::{
|
||||
env,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use log::LevelFilter;
|
||||
use lsp::{
|
||||
spawn_lsp_service, supported_servers::LSPServerType, LspServerConfig, LspService,
|
||||
LspServiceInitializationResult,
|
||||
};
|
||||
use lsp_types::Position;
|
||||
use warpui::r#async::{executor::Background, Timer};
|
||||
|
||||
fn init_logging() {
|
||||
let mut base_logger = env_logger::builder();
|
||||
base_logger.filter_level(LevelFilter::Info);
|
||||
base_logger.parse_default_env();
|
||||
base_logger.init();
|
||||
}
|
||||
|
||||
fn find_workspace_root() -> anyhow::Result<PathBuf> {
|
||||
let current_dir = env::current_dir()?;
|
||||
|
||||
// Walk up directory tree to find Cargo.toml (workspace root)
|
||||
let mut path = current_dir.as_path();
|
||||
loop {
|
||||
if path.join("Cargo.toml").exists() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
match path.parent() {
|
||||
Some(parent) => path = parent,
|
||||
None => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Could not find workspace root with Cargo.toml"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn demo_goto_definition(
|
||||
service: &LspService,
|
||||
file_path: &Path,
|
||||
_content: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
println!("\n=== Testing Go-to-Definition ===");
|
||||
|
||||
// This attempts to target find_workspace_root currently on line 105
|
||||
// Note that "lines" are 0-indexed
|
||||
let test_position = Position {
|
||||
line: 92,
|
||||
character: 7,
|
||||
};
|
||||
|
||||
println!(
|
||||
"Testing go-to-definition at line {}, character {}",
|
||||
test_position.line, test_position.character
|
||||
);
|
||||
|
||||
let start = Utc::now();
|
||||
|
||||
// Use the text document service instead of direct send_request
|
||||
match service
|
||||
.text_document()
|
||||
.definition(file_path, test_position)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
println!("Definition response: {response:?}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error requesting definition: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = Utc::now() - start;
|
||||
println!("Elapsed time for goto-definition: {elapsed:?}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Main demo function
|
||||
fn main() -> anyhow::Result<()> {
|
||||
init_logging();
|
||||
|
||||
println!("Starting LSP Crate Demonstration");
|
||||
println!("Using rust-analyzer as the LSP server");
|
||||
|
||||
// === Setup Phase ===
|
||||
|
||||
// Find workspace root for testing
|
||||
let workspace_root = find_workspace_root()?;
|
||||
|
||||
log::info!("Workspace root: {}", workspace_root.display());
|
||||
|
||||
let executor = Arc::new(Background::default());
|
||||
let executor_clone = executor.clone();
|
||||
|
||||
let task = executor.spawn(async move {
|
||||
if let Err(e) = async_main(executor_clone, workspace_root).await {
|
||||
log::error!("LSP demo failed: {e}");
|
||||
eprintln!("LSP demo failed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
warpui::r#async::block_on(task)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn async_main(executor: Arc<Background>, workspace_root: PathBuf) -> anyhow::Result<()> {
|
||||
println!("Initializing LSP Server (rust-analyzer)...");
|
||||
|
||||
let config = LspServerConfig::new(
|
||||
LSPServerType::RustAnalyzer,
|
||||
workspace_root,
|
||||
None,
|
||||
"warp-dev-example".to_string(),
|
||||
Arc::new(http_client::Client::new()),
|
||||
);
|
||||
|
||||
let LspServiceInitializationResult {
|
||||
service: lsp_service,
|
||||
channel: _rx,
|
||||
} = spawn_lsp_service(config, executor, None).await?;
|
||||
|
||||
if let Some(capabilities) = lsp_service.server_capabilities() {
|
||||
println!("Server capabilities received");
|
||||
if capabilities.definition_provider.is_some() {
|
||||
println!(" - Go-to-definition supported");
|
||||
}
|
||||
if capabilities.hover_provider.is_some() {
|
||||
println!(" - Hover information supported");
|
||||
}
|
||||
if capabilities.completion_provider.is_some() {
|
||||
println!(" - Code completion supported");
|
||||
}
|
||||
}
|
||||
|
||||
// Use this main.rs file as our test document
|
||||
let test_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/rust-lsp/main.rs");
|
||||
let file_content = std::fs::read_to_string(&test_file)?;
|
||||
|
||||
// This ensures rust-analyzer has finished its initial indexing and setup
|
||||
println!("Waiting 30 seconds for LSP service to be ready...");
|
||||
Timer::after(Duration::from_secs(30)).await;
|
||||
|
||||
println!("Opening document: {}", test_file.display());
|
||||
|
||||
lsp_service
|
||||
.text_document()
|
||||
.did_open(&test_file, file_content.clone(), 0)
|
||||
.await?;
|
||||
|
||||
println!("Running first goto-definition call");
|
||||
demo_goto_definition(&lsp_service, &test_file, &file_content).await?;
|
||||
|
||||
println!("Running second goto-definition call");
|
||||
demo_goto_definition(&lsp_service, &test_file, &file_content).await?;
|
||||
|
||||
println!("Shutting down LSP service...");
|
||||
|
||||
lsp_service.shutdown().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use command::r#async::Command;
|
||||
|
||||
/// A wrapper around `path_env_var` that produces correctly-configured commands.
|
||||
///
|
||||
/// This follows the same wrapping pattern as `command::r#async::Command`:
|
||||
/// callers construct commands through the executor, which transparently sets
|
||||
/// the PATH environment variable. On wasm, a dummy implementation is provided
|
||||
/// so that consumer code doesn't need cfg gating.
|
||||
#[derive(Clone)]
|
||||
pub struct CommandBuilder {
|
||||
path_env_var: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandBuilder {
|
||||
/// Creates a new CommandBuilder with the given PATH environment variable.
|
||||
pub fn new(path_env_var: Option<String>) -> Self {
|
||||
Self { path_env_var }
|
||||
}
|
||||
|
||||
/// Returns the PATH environment variable, if set.
|
||||
pub fn path_env_var(&self) -> Option<&str> {
|
||||
self.path_env_var.as_deref()
|
||||
}
|
||||
|
||||
/// Creates a new Command with PATH already set.
|
||||
///
|
||||
/// Use this when you need to run a command. The returned Command has the
|
||||
/// same API as `command::r#async::Command`, so callers don't need to change
|
||||
/// how they construct commands.
|
||||
///
|
||||
/// On Windows, the command is wrapped in `cmd.exe /c` so that `.cmd` and
|
||||
/// `.bat` scripts on PATH are resolved correctly (e.g. `npm.cmd`,
|
||||
/// `typescript-language-server.cmd`). Rust's `Command::new` uses
|
||||
/// `CreateProcessW` which only resolves `.exe` extensions.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn command(&self, program: impl AsRef<std::ffi::OsStr>) -> Command {
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut cmd = Command::new("cmd.exe");
|
||||
cmd.arg("/c").arg(program);
|
||||
cmd
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let mut cmd = Command::new(program);
|
||||
if let Some(path) = &self.path_env_var {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use command::r#async::Command;
|
||||
use lsp_types::{
|
||||
ClientCapabilities, ClientInfo, DidChangeWatchedFilesClientCapabilities, GotoCapability,
|
||||
HoverClientCapabilities, InitializeParams, MarkupKind, PublishDiagnosticsClientCapabilities,
|
||||
TextDocumentClientCapabilities, TextDocumentSyncClientCapabilities, Uri,
|
||||
WindowClientCapabilities, WorkDoneProgressParams, WorkspaceClientCapabilities, WorkspaceFolder,
|
||||
};
|
||||
|
||||
use crate::supported_servers::LSPServerType;
|
||||
|
||||
/// Result of resolving an LSP server command, including the command and init params.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct ResolvedLspCommand {
|
||||
pub command: Command,
|
||||
pub params: InitializeParams,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LanguageId {
|
||||
Rust,
|
||||
Go,
|
||||
Python,
|
||||
TypeScript,
|
||||
TypeScriptReact,
|
||||
JavaScript,
|
||||
JavaScriptReact,
|
||||
C,
|
||||
Cpp,
|
||||
}
|
||||
|
||||
impl LanguageId {
|
||||
pub fn from_path(path: &Path) -> Option<Self> {
|
||||
let extn = path.extension()?;
|
||||
match extn.to_str()? {
|
||||
"rs" => Some(Self::Rust),
|
||||
"go" => Some(Self::Go),
|
||||
"py" => Some(Self::Python),
|
||||
"ts" => Some(Self::TypeScript),
|
||||
"tsx" => Some(Self::TypeScriptReact),
|
||||
"js" | "mjs" | "cjs" => Some(Self::JavaScript),
|
||||
"jsx" => Some(Self::JavaScriptReact),
|
||||
"c" | "C" => Some(Self::C),
|
||||
"cc" | "cpp" | "cxx" => Some(Self::Cpp),
|
||||
// NOTE: `.h` files are ambiguous (could be C or C++). We map them to Cpp
|
||||
// because clangd defaults to C++ for `.h` files anyway. When a
|
||||
// compile_commands.json is present, clangd will use the correct language
|
||||
// regardless of the languageId we send.
|
||||
"h" | "H" | "hh" | "hpp" | "hxx" => Some(Self::Cpp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the language identifier as used by LSP.
|
||||
/// See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem
|
||||
pub(crate) fn lsp_language_identifier(&self) -> &'static str {
|
||||
match self {
|
||||
LanguageId::Rust => "rust",
|
||||
LanguageId::Go => "go",
|
||||
LanguageId::Python => "python",
|
||||
LanguageId::TypeScript => "typescript",
|
||||
LanguageId::TypeScriptReact => "typescriptreact",
|
||||
LanguageId::JavaScript => "javascript",
|
||||
LanguageId::JavaScriptReact => "javascriptreact",
|
||||
LanguageId::C => "c",
|
||||
LanguageId::Cpp => "cpp",
|
||||
}
|
||||
}
|
||||
|
||||
/// For now we assume a 1:1 language -> LSP server type. This might change in the future as we support more configurabilities.
|
||||
pub fn server_type(&self) -> LSPServerType {
|
||||
match self {
|
||||
LanguageId::Rust => LSPServerType::RustAnalyzer,
|
||||
LanguageId::Go => LSPServerType::GoPls,
|
||||
LanguageId::Python => LSPServerType::Pyright,
|
||||
LanguageId::TypeScript
|
||||
| LanguageId::TypeScriptReact
|
||||
| LanguageId::JavaScript
|
||||
| LanguageId::JavaScriptReact => LSPServerType::TypeScriptLanguageServer,
|
||||
LanguageId::C | LanguageId::Cpp => LSPServerType::Clangd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for spawning an LSP server process.
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub struct LspServerConfig {
|
||||
server_type: LSPServerType,
|
||||
initial_workspace: PathBuf,
|
||||
/// The local PATH variable set when starting the server. This is needed when the app is started
|
||||
/// without a shell based parent process.
|
||||
/// TODO(kevin): This might not be sufficient for all cases (e.g. user might remove LSP from PATH).
|
||||
path_env_var: Option<String>,
|
||||
client_name: String,
|
||||
/// Shared HTTP client used for LSP installation checks and downloads.
|
||||
client: Arc<http_client::Client>,
|
||||
/// Optional path relative to the LSP log namespace for server stderr output.
|
||||
log_relative_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LspServerConfig {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("LspServerConfig")
|
||||
.field("server_type", &self.server_type)
|
||||
.field("initial_workspace", &self.initial_workspace)
|
||||
.field("path_env_var", &self.path_env_var)
|
||||
.field("client_name", &self.client_name)
|
||||
.field("log_relative_path", &self.log_relative_path)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LspServerConfig {
|
||||
pub fn new(
|
||||
server_type: LSPServerType,
|
||||
initial_workspace: PathBuf,
|
||||
path_env_var: Option<String>,
|
||||
client_name: String,
|
||||
client: Arc<http_client::Client>,
|
||||
) -> Self {
|
||||
Self {
|
||||
server_type,
|
||||
initial_workspace,
|
||||
path_env_var,
|
||||
client_name,
|
||||
client,
|
||||
log_relative_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the relative log path for this server's stderr output.
|
||||
pub fn with_log_relative_path(mut self, log_relative_path: PathBuf) -> Self {
|
||||
self.log_relative_path = Some(log_relative_path);
|
||||
self
|
||||
}
|
||||
/// Returns the relative log path if configured.
|
||||
pub fn log_relative_path(&self) -> Option<&PathBuf> {
|
||||
self.log_relative_path.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the initial workspace path.
|
||||
pub fn initial_workspace(&self) -> &Path {
|
||||
&self.initial_workspace
|
||||
}
|
||||
|
||||
pub(crate) fn server_name(&self) -> String {
|
||||
self.server_type.binary_name().to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn languages(&self) -> Vec<LanguageId> {
|
||||
self.server_type.languages()
|
||||
}
|
||||
|
||||
/// Creates the command and init params for the LSP server.
|
||||
///
|
||||
/// PATH takes precedence over custom installations. If the binary is available
|
||||
/// and working on PATH, we use that. Otherwise, we fall back to our custom installation.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) async fn command_and_params(self) -> Result<ResolvedLspCommand> {
|
||||
// PATH takes precedence - only use custom installation if not working on PATH
|
||||
let executor = crate::CommandBuilder::new(self.path_env_var.clone());
|
||||
let is_working_on_path = self
|
||||
.server_type
|
||||
.is_working_on_path(&executor, self.client.clone())
|
||||
.await;
|
||||
let custom_binary_config = if is_working_on_path {
|
||||
// Binary works on PATH, don't use custom installation
|
||||
None
|
||||
} else {
|
||||
// Not working on PATH, check for custom installation
|
||||
self.server_type
|
||||
.find_installed_binary_config(executor.path_env_var())
|
||||
.await
|
||||
};
|
||||
|
||||
// Bail early with a clear error instead of attempting to spawn a
|
||||
// binary that doesn't exist (which would fail with a confusing
|
||||
// "No such file or directory" OS error).
|
||||
if !is_working_on_path && custom_binary_config.is_none() {
|
||||
anyhow::bail!(
|
||||
"{} is not installed. Binary was not found on PATH and no custom installation exists",
|
||||
self.server_type.binary_name()
|
||||
);
|
||||
}
|
||||
|
||||
let mut command = self
|
||||
.server_type
|
||||
.create_command(custom_binary_config.clone(), &executor);
|
||||
|
||||
// Set the working directory to the workspace root. This is required for
|
||||
// LSP servers like rust-analyzer to properly discover the project structure.
|
||||
command.current_dir(&self.initial_workspace);
|
||||
|
||||
log::info!(
|
||||
"LSP {} starting with custom_binary_config: {:?}",
|
||||
self.server_type.binary_name(),
|
||||
custom_binary_config
|
||||
);
|
||||
|
||||
let params = default_init_params(&self.initial_workspace, self.client_name)?;
|
||||
|
||||
Ok(ResolvedLspCommand { command, params })
|
||||
}
|
||||
|
||||
pub(crate) fn server_type(&self) -> LSPServerType {
|
||||
self.server_type
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn path_to_lsp_uri(path: &Path) -> Result<Uri> {
|
||||
if !path.is_absolute() {
|
||||
return Err(anyhow::anyhow!("Path must be absolute: {}", path.display()));
|
||||
}
|
||||
|
||||
// url::Url::from_file_path handles percent-encoding internally but is not
|
||||
// available on WASM. LSP is not supported on WASM either, so the fallback
|
||||
// is a simple string concatenation.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let url = url::Url::from_file_path(path).map_err(|()| {
|
||||
anyhow::anyhow!("Failed to convert path to file URI: {}", path.display())
|
||||
})?;
|
||||
|
||||
// The url crate doesn't encode brackets, but LSP requires them to be
|
||||
// percent-encoded (e.g. Next.js [slug].tsx routes).
|
||||
let uri_str = url.as_str().replace('[', "%5B").replace(']', "%5D");
|
||||
|
||||
uri_str.parse::<Uri>().map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let path_str = path.to_string_lossy();
|
||||
let uri_string = format!("file://{path_str}");
|
||||
uri_string.parse::<Uri>().map_err(anyhow::Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lsp_uri_to_path(uri: &Uri) -> Result<PathBuf> {
|
||||
// Validate this is a file URI
|
||||
let scheme = uri.scheme().map(|s| s.as_str());
|
||||
if scheme != Some("file") {
|
||||
return Err(anyhow::anyhow!("Invalid file URI: {}", uri.as_str()));
|
||||
}
|
||||
|
||||
// Decode percent-encoded characters (e.g., %40 -> @)
|
||||
// This is necessary because LSP servers return URL-encoded paths
|
||||
let decoded_path = uri
|
||||
.path()
|
||||
.as_estr()
|
||||
.decode()
|
||||
.into_string()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid UTF-8 in URI path: {e}"))?;
|
||||
|
||||
let mut path_str: &str = decoded_path.as_ref();
|
||||
|
||||
// Windows URIs are formatted like: file:///C:/path/to/file
|
||||
// The path component is `/C:/path/to/file`, strip the leading slash.
|
||||
if cfg!(windows) {
|
||||
path_str = path_str.strip_prefix('/').unwrap_or(path_str);
|
||||
return Ok(PathBuf::from(path_str.replace('/', "\\")));
|
||||
}
|
||||
|
||||
Ok(PathBuf::from(path_str))
|
||||
}
|
||||
|
||||
fn path_to_workspace_folder(path: &Path) -> Result<WorkspaceFolder> {
|
||||
path_to_lsp_uri(path).map(|url| WorkspaceFolder {
|
||||
uri: url,
|
||||
name: path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn default_client_capabilities() -> ClientCapabilities {
|
||||
ClientCapabilities {
|
||||
workspace: Some(WorkspaceClientCapabilities {
|
||||
did_change_watched_files: Option::from(DidChangeWatchedFilesClientCapabilities {
|
||||
dynamic_registration: Some(true),
|
||||
relative_pattern_support: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
window: Some(WindowClientCapabilities {
|
||||
work_done_progress: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
text_document: Some(TextDocumentClientCapabilities {
|
||||
synchronization: Some(TextDocumentSyncClientCapabilities {
|
||||
dynamic_registration: Some(true),
|
||||
will_save: Some(false),
|
||||
will_save_wait_until: Some(false),
|
||||
did_save: Some(true),
|
||||
}),
|
||||
definition: Some(GotoCapability {
|
||||
dynamic_registration: Some(false),
|
||||
link_support: Some(true),
|
||||
}),
|
||||
hover: Some(HoverClientCapabilities {
|
||||
dynamic_registration: Some(false),
|
||||
// Request Markdown content from the LSP for hover responses.
|
||||
// This enables proper syntax highlighting in hover tooltips.
|
||||
content_format: Some(vec![MarkupKind::Markdown, MarkupKind::PlainText]),
|
||||
}),
|
||||
publish_diagnostics: Some(PublishDiagnosticsClientCapabilities {
|
||||
version_support: Some(true),
|
||||
related_information: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_init_params(workspace_uri: &Path, client_name: String) -> Result<InitializeParams> {
|
||||
let workspace_folder = path_to_workspace_folder(workspace_uri)?;
|
||||
|
||||
Ok(InitializeParams {
|
||||
process_id: Some(std::process::id()),
|
||||
capabilities: default_client_capabilities(),
|
||||
workspace_folders: Some(vec![workspace_folder]),
|
||||
client_info: Some(ClientInfo {
|
||||
name: client_name,
|
||||
version: option_env!("GIT_RELEASE_TAG").map(|s| s.to_string()),
|
||||
}),
|
||||
locale: None,
|
||||
work_done_progress_params: WorkDoneProgressParams::default(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,218 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lsp_types::Uri;
|
||||
|
||||
use crate::config::{lsp_uri_to_path, path_to_lsp_uri};
|
||||
|
||||
// Unix-specific tests use Unix paths
|
||||
#[cfg(not(windows))]
|
||||
mod unix_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_basic() {
|
||||
let uri: Uri = "file:///Users/test/project/src/main.rs".parse().unwrap();
|
||||
let path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(path, PathBuf::from("/Users/test/project/src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_decodes_at_symbol() {
|
||||
// %40 is the URL encoding for @
|
||||
let uri: Uri = "file:///Users/test/node_modules/%40firebase/auth/dist/index.d.ts"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/Users/test/node_modules/@firebase/auth/dist/index.d.ts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_decodes_spaces() {
|
||||
// %20 is the URL encoding for space
|
||||
let uri: Uri = "file:///Users/test/My%20Project/src/main.rs"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(path, PathBuf::from("/Users/test/My Project/src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_decodes_multiple_special_chars() {
|
||||
// Test multiple encoded characters: @ (%40), space (%20), # (%23)
|
||||
let uri: Uri = "file:///Users/test/%40scope/my%20package%23v1/index.ts"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/Users/test/@scope/my package#v1/index.ts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_lsp_uri_basic() {
|
||||
let path = PathBuf::from("/Users/test/project/src/main.rs");
|
||||
let uri = path_to_lsp_uri(&path).unwrap();
|
||||
assert_eq!(uri.as_str(), "file:///Users/test/project/src/main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_lsp_uri_encodes_spaces() {
|
||||
let path = PathBuf::from("/Users/test/My Project/src/main.rs");
|
||||
let uri = path_to_lsp_uri(&path).unwrap();
|
||||
assert_eq!(uri.as_str(), "file:///Users/test/My%20Project/src/main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_lsp_uri_encodes_non_ascii() {
|
||||
let path = PathBuf::from("/Users/관리자/project/src/main.rs");
|
||||
let uri = path_to_lsp_uri(&path).unwrap();
|
||||
assert!(uri.as_str().starts_with("file:///Users/%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_lsp_uri_encodes_accented_chars() {
|
||||
let path = PathBuf::from("/Users/José/project/src/main.rs");
|
||||
let uri = path_to_lsp_uri(&path).unwrap();
|
||||
assert!(uri.as_str().starts_with("file:///Users/Jos%"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_lsp_uri_encodes_hash() {
|
||||
let path = PathBuf::from("/Users/test/my#project/src/main.rs");
|
||||
let uri = path_to_lsp_uri(&path).unwrap();
|
||||
assert_eq!(uri.as_str(), "file:///Users/test/my%23project/src/main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_path_to_uri_to_path() {
|
||||
let original_path = PathBuf::from("/Users/test/project/src/main.rs");
|
||||
let uri = path_to_lsp_uri(&original_path).unwrap();
|
||||
let roundtrip_path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(original_path, roundtrip_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_non_ascii_path() {
|
||||
let original_path = PathBuf::from("/Users/관리자/project/src/main.rs");
|
||||
let uri = path_to_lsp_uri(&original_path).unwrap();
|
||||
let roundtrip_path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(original_path, roundtrip_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_path_with_spaces() {
|
||||
let original_path = PathBuf::from("/Users/test/My Project/src/main.rs");
|
||||
let uri = path_to_lsp_uri(&original_path).unwrap();
|
||||
let roundtrip_path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(original_path, roundtrip_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_lsp_uri_encodes_brackets() {
|
||||
let path = PathBuf::from("/Users/test/routes/blog/[slug].tsx");
|
||||
let uri = path_to_lsp_uri(&path).unwrap();
|
||||
assert_eq!(
|
||||
uri.as_str(),
|
||||
"file:///Users/test/routes/blog/%5Bslug%5D.tsx"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_path_with_brackets() {
|
||||
let original_path = PathBuf::from("/Users/test/routes/[id]/[slug].tsx");
|
||||
let uri = path_to_lsp_uri(&original_path).unwrap();
|
||||
let roundtrip_path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(original_path, roundtrip_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Windows-specific tests use Windows paths
|
||||
#[cfg(windows)]
|
||||
mod windows_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_basic() {
|
||||
let uri: Uri = "file:///C:/Users/test/project/src/main.rs".parse().unwrap();
|
||||
let path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("C:\\Users\\test\\project\\src\\main.rs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_decodes_at_symbol() {
|
||||
// %40 is the URL encoding for @
|
||||
let uri: Uri = "file:///C:/Users/test/node_modules/%40firebase/auth/dist/index.d.ts"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("C:\\Users\\test\\node_modules\\@firebase\\auth\\dist\\index.d.ts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_decodes_spaces() {
|
||||
// %20 is the URL encoding for space
|
||||
let uri: Uri = "file:///C:/Users/test/My%20Project/src/main.rs"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("C:\\Users\\test\\My Project\\src\\main.rs")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_decodes_multiple_special_chars() {
|
||||
// Test multiple encoded characters: @ (%40), space (%20), # (%23)
|
||||
let uri: Uri = "file:///C:/Users/test/%40scope/my%20package%23v1/index.ts"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("C:\\Users\\test\\@scope\\my package#v1\\index.ts")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_lsp_uri_basic() {
|
||||
let path = PathBuf::from("C:\\Users\\test\\project\\src\\main.rs");
|
||||
let uri = path_to_lsp_uri(&path).unwrap();
|
||||
assert_eq!(uri.as_str(), "file:///C:/Users/test/project/src/main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_path_to_uri_to_path() {
|
||||
let original_path = PathBuf::from("C:\\Users\\test\\project\\src\\main.rs");
|
||||
let uri = path_to_lsp_uri(&original_path).unwrap();
|
||||
let roundtrip_path = lsp_uri_to_path(&uri).unwrap();
|
||||
assert_eq!(original_path, roundtrip_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-independent tests
|
||||
#[test]
|
||||
fn test_lsp_uri_to_path_rejects_non_file_uri() {
|
||||
let uri: Uri = "https://example.com/path".parse().unwrap();
|
||||
let result = lsp_uri_to_path(&uri);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Invalid file URI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_to_lsp_uri_rejects_relative_path() {
|
||||
let path = PathBuf::from("relative/path/file.rs");
|
||||
let result = path_to_lsp_uri(&path);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("must be absolute"));
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[cfg(all(feature = "local_fs", unix))]
|
||||
use async_fs::unix::PermissionsExt;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::PathBuf;
|
||||
}
|
||||
}
|
||||
|
||||
use crate::language_server_candidate::LanguageServerMetadata;
|
||||
|
||||
const GITHUB_API_URL: &str = "https://api.github.com";
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GithubReleaseAsset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct GithubReleaseAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
digest: Option<String>,
|
||||
}
|
||||
|
||||
/// Finds a named asset in a release and returns its download URL and optional SHA256 digest.
|
||||
fn resolve_asset(
|
||||
assets: Vec<GithubReleaseAsset>,
|
||||
asset_name: &str,
|
||||
) -> Result<(String, Option<String>)> {
|
||||
let asset = assets
|
||||
.into_iter()
|
||||
.find(|a| a.name == asset_name)
|
||||
.with_context(|| format!("Asset '{asset_name}' not found in release"))?;
|
||||
|
||||
// Strip the "sha256:" prefix from the digest if present
|
||||
let digest = asset
|
||||
.digest
|
||||
.map(|d| d.strip_prefix("sha256:").unwrap_or(&d).to_string());
|
||||
|
||||
Ok((asset.browser_download_url, digest))
|
||||
}
|
||||
|
||||
async fn fetch_latest_release_from_github(
|
||||
client: &http_client::Client,
|
||||
repo_owner: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<GithubRelease> {
|
||||
let url = format!(
|
||||
"{}/repos/{}/{}/releases/latest",
|
||||
GITHUB_API_URL, repo_owner, repo_name
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
// GitHub API recommends specifying these parameters in the header.
|
||||
// See: https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api#user-agent
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", "warp-terminal")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to fetch latest release from GitHub")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"GitHub API returned status {}: {}",
|
||||
response.status(),
|
||||
response.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse GitHub release response")
|
||||
}
|
||||
|
||||
/// Fetches the latest release metadata from a GitHub repository.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `client` - The HTTP client to use for the request
|
||||
/// * `repo_owner` - The owner of the GitHub repository (e.g. "rust-lang")
|
||||
/// * `repo_name` - The name of the GitHub repository (e.g. "rust-analyzer")
|
||||
/// * `asset_name` - The name of the asset to find in the release. If None, no asset
|
||||
/// lookup is performed and url/digest will be None (useful for servers like gopls
|
||||
/// that don't provide prebuilt binaries).
|
||||
///
|
||||
/// # Returns
|
||||
/// A `LanguageServerMetadata` containing the version, optional download URL, and optional SHA256 digest.
|
||||
pub async fn fetch_latest_metadata_from_github(
|
||||
client: &http_client::Client,
|
||||
repo_owner: &str,
|
||||
repo_name: &str,
|
||||
asset_name: Option<&str>,
|
||||
) -> Result<LanguageServerMetadata> {
|
||||
let release = fetch_latest_release_from_github(client, repo_owner, repo_name).await?;
|
||||
|
||||
// If an asset name is provided, look up the asset details
|
||||
let (download_url, digest) = if let Some(asset_name) = asset_name {
|
||||
let (url, digest) = resolve_asset(release.assets, asset_name)?;
|
||||
(Some(url), digest)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
Ok(LanguageServerMetadata {
|
||||
version: release.tag_name,
|
||||
url: download_url,
|
||||
digest,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches the latest release metadata from a GitHub repository and resolves
|
||||
/// an asset name dynamically based on the latest release tag.
|
||||
///
|
||||
/// This is useful for projects where asset names include the tag itself, e.g.
|
||||
/// `clangd-mac-v21.0.0.zip`.
|
||||
pub async fn fetch_latest_metadata_from_github_dynamic_asset<F>(
|
||||
client: &http_client::Client,
|
||||
repo_owner: &str,
|
||||
repo_name: &str,
|
||||
asset_name_for_tag: F,
|
||||
) -> Result<LanguageServerMetadata>
|
||||
where
|
||||
F: FnOnce(&str) -> String,
|
||||
{
|
||||
let release = fetch_latest_release_from_github(client, repo_owner, repo_name).await?;
|
||||
let asset_name = asset_name_for_tag(&release.tag_name);
|
||||
let (url, digest) = resolve_asset(release.assets, &asset_name)?;
|
||||
|
||||
Ok(LanguageServerMetadata {
|
||||
version: release.tag_name,
|
||||
url: Some(url),
|
||||
digest,
|
||||
})
|
||||
}
|
||||
|
||||
/// The type of archive for a GitHub release asset.
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AssetKind {
|
||||
/// A gzip-compressed file (e.g., `rust-analyzer-aarch64-apple-darwin.gz`)
|
||||
Gz,
|
||||
/// A zip archive (e.g., `rust-analyzer-x86_64-pc-windows-msvc.zip`)
|
||||
Zip,
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl AssetKind {
|
||||
/// Determines the asset kind from a file name based on its extension.
|
||||
pub fn from_filename(filename: &str) -> Option<Self> {
|
||||
if filename.ends_with(".gz") && !filename.ends_with(".tar.gz") {
|
||||
Some(AssetKind::Gz)
|
||||
} else if filename.ends_with(".zip") {
|
||||
Some(AssetKind::Zip)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads and installs a language server binary from GitHub.
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Downloads the binary from the provided URL
|
||||
/// 2. Verifies the SHA256 checksum if provided
|
||||
/// 3. Extracts/decompresses the archive based on its type
|
||||
/// 4. Makes the binary executable (on Unix systems)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `client` - The HTTP client to use for downloading
|
||||
/// * `metadata` - The server metadata containing version, URL, and optional digest
|
||||
/// * `server_name` - The name of the server (e.g., "rust-analyzer") used for the destination path
|
||||
/// * `asset_kind` - The type of archive (Gz or Zip)
|
||||
/// * `binary_finder` - Optional callback to locate the binary after extraction. When provided,
|
||||
/// the full archive is extracted (no filter) and this function is called to find the binary.
|
||||
/// When `None`, a name-based filter is used during extraction and the binary is assumed to be
|
||||
/// at `{install_dir}/{server_name}`.
|
||||
///
|
||||
/// # Returns
|
||||
/// The path to the installed binary on success.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn install_from_github(
|
||||
client: &http_client::Client,
|
||||
metadata: &LanguageServerMetadata,
|
||||
server_name: &str,
|
||||
asset_kind: AssetKind,
|
||||
binary_finder: Option<fn(&std::path::Path) -> Option<PathBuf>>,
|
||||
) -> Result<PathBuf> {
|
||||
let url = metadata
|
||||
.url
|
||||
.as_ref()
|
||||
.context("No download URL provided in metadata")?;
|
||||
|
||||
// Create the destination directory: {data_dir}/{server_name}/{version}
|
||||
// If it already exists, remove it first to ensure a clean installation
|
||||
let install_dir = warp_core::paths::data_dir()
|
||||
.join(server_name)
|
||||
.join(&metadata.version);
|
||||
if install_dir.exists() {
|
||||
async_fs::remove_dir_all(&install_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to remove existing install directory: {:?}",
|
||||
install_dir
|
||||
)
|
||||
})?;
|
||||
}
|
||||
async_fs::create_dir_all(&install_dir)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create install directory: {:?}", install_dir))?;
|
||||
|
||||
// The binary name is the server name (with .exe on Windows)
|
||||
let binary_name = if cfg!(windows) {
|
||||
format!("{server_name}.exe")
|
||||
} else {
|
||||
server_name.to_string()
|
||||
};
|
||||
|
||||
// Download the file
|
||||
log::info!("Downloading {server_name} from {url}");
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to download from {url}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"Download failed with status {}: {}",
|
||||
response.status(),
|
||||
response.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response body")?;
|
||||
|
||||
// Verify checksum if provided
|
||||
if let Some(expected_digest) = &metadata.digest {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let actual_digest = format!("{:x}", hasher.finalize());
|
||||
|
||||
if actual_digest != *expected_digest {
|
||||
anyhow::bail!(
|
||||
"SHA256 checksum mismatch for {server_name}. Expected: {expected_digest}, Got: {actual_digest}"
|
||||
);
|
||||
}
|
||||
log::info!("Checksum verified for {server_name}");
|
||||
}
|
||||
|
||||
// Extract the archive based on type
|
||||
match asset_kind {
|
||||
AssetKind::Gz => {
|
||||
let binary_path = install_dir.join(&binary_name);
|
||||
node_runtime::extract_gz(&bytes, &binary_path).await?;
|
||||
}
|
||||
AssetKind::Zip => {
|
||||
if binary_finder.is_some() {
|
||||
// Extract the full archive when using a custom binary finder
|
||||
let no_filter: Option<fn(&str) -> bool> = None;
|
||||
node_runtime::extract_zip(&bytes, &install_dir, no_filter).await?;
|
||||
} else {
|
||||
// Use a filter to extract only the specific binary we need
|
||||
let binary_name_clone = binary_name.clone();
|
||||
node_runtime::extract_zip(
|
||||
&bytes,
|
||||
&install_dir,
|
||||
Some(move |file_name: &str| {
|
||||
file_name.ends_with(&binary_name_clone)
|
||||
|| file_name.ends_with(&format!("/{binary_name_clone}"))
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Locate the binary
|
||||
let binary_path = if let Some(finder) = binary_finder {
|
||||
finder(&install_dir)
|
||||
.with_context(|| format!("Failed to locate {server_name} binary after extraction"))?
|
||||
} else {
|
||||
install_dir.join(&binary_name)
|
||||
};
|
||||
|
||||
// Make the binary executable on Unix systems
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut perms = async_fs::metadata(&binary_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to get metadata for {:?}", binary_path))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
async_fs::set_permissions(&binary_path, perms)
|
||||
.await
|
||||
.with_context(|| format!("Failed to set permissions for {:?}", binary_path))?;
|
||||
}
|
||||
|
||||
log::info!("Successfully installed {server_name} to {:?}", binary_path);
|
||||
Ok(binary_path)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::CommandBuilder;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Defines the detection and installation for a specific Language Server.
|
||||
///
|
||||
/// This trait allows us to decouple the specific logic for each server (installation,
|
||||
/// detection, etc.) from the main application logic.
|
||||
#[async_trait]
|
||||
pub trait LanguageServerCandidate: Send + Sync {
|
||||
/// Heuristic to determine if this server is relevant for the repo at the given path.
|
||||
///
|
||||
/// For example, a Rust server might check for `Cargo.toml` or `*.rs` files.
|
||||
/// The executor is provided for servers that need to check if a runtime is available
|
||||
/// (e.g. gopls checks if `go` is installed).
|
||||
async fn should_suggest_for_repo(&self, path: &Path, executor: &CommandBuilder) -> bool;
|
||||
|
||||
/// Checks if the server binary is installed in our custom data directory.
|
||||
///
|
||||
/// The executor is provided for servers that need to locate runtime dependencies
|
||||
/// (e.g. pyright needs to find node).
|
||||
async fn is_installed_in_data_dir(&self, executor: &CommandBuilder) -> bool;
|
||||
|
||||
/// Checks if the server binary is available and working on the system PATH.
|
||||
///
|
||||
/// Returns true only if the binary executes successfully with exit code 0.
|
||||
async fn is_installed_on_path(&self, executor: &CommandBuilder) -> bool;
|
||||
|
||||
/// Checks if the server binary is currently available/executable.
|
||||
///
|
||||
/// By default, checks the data directory first, then falls back to PATH.
|
||||
async fn is_installed(&self, executor: &CommandBuilder) -> bool {
|
||||
// First check if installed in our custom location
|
||||
if self.is_installed_in_data_dir(executor).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to checking PATH
|
||||
self.is_installed_on_path(executor).await
|
||||
}
|
||||
|
||||
/// Attempts to install the server into the `.warp/` directory.
|
||||
///
|
||||
/// The executor provides the user's PATH environment variable, which may be needed
|
||||
/// for servers that rely on external tools (e.g. gopls needs `go`).
|
||||
async fn install(
|
||||
&self,
|
||||
metadata: LanguageServerMetadata,
|
||||
executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata>;
|
||||
}
|
||||
|
||||
pub struct LanguageServerMetadata {
|
||||
pub version: String,
|
||||
/// The download URL for the server binary. None if the server cannot be
|
||||
/// downloaded directly (e.g. gopls which must be installed via `go install`).
|
||||
pub url: Option<String>,
|
||||
pub digest: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
mod command_builder;
|
||||
pub use command_builder::CommandBuilder;
|
||||
|
||||
mod config;
|
||||
mod language_server_candidate;
|
||||
pub use language_server_candidate::LanguageServerCandidate;
|
||||
pub mod install;
|
||||
mod manager;
|
||||
mod model;
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "server_repo_watcher.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "server_repo_watcher_wasm.rs")]
|
||||
mod server_repo_watcher;
|
||||
|
||||
pub mod servers;
|
||||
mod service;
|
||||
pub mod supported_servers;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod transport;
|
||||
pub mod types;
|
||||
|
||||
pub use config::{default_init_params, LanguageId, LspServerConfig};
|
||||
pub use jsonrpc::{JsonRpcService, ServerNotificationEvent, Transport};
|
||||
pub use lsp_types::{
|
||||
notification::{self},
|
||||
Position, Range,
|
||||
};
|
||||
pub use manager::{LspManagerModel, LspManagerModelEvent};
|
||||
pub use model::{
|
||||
BackgroundTaskInfo, DocumentDiagnostics, LanguageServerId, LspEvent, LspServerModel, LspState,
|
||||
};
|
||||
pub use service::LspService;
|
||||
pub use types::{HoverContents, HoverResult, MarkupKind, ReferenceLocation};
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LspServerLogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LspServerLogLevel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let level = match self {
|
||||
Self::Debug => "debug",
|
||||
Self::Info => "info",
|
||||
Self::Warn => "warn",
|
||||
Self::Error => "error",
|
||||
};
|
||||
f.write_str(level)
|
||||
}
|
||||
}
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use simple_logger::SimpleLogger;
|
||||
use std::sync::Arc;
|
||||
use warpui::r#async::executor::Background;
|
||||
use warpui::AppContext;
|
||||
|
||||
pub struct LspServiceInitializationResult {
|
||||
pub service: LspService,
|
||||
pub channel: async_channel::Receiver<ServerNotificationEvent>,
|
||||
}
|
||||
|
||||
/// Creates a complete LspService from an LSP server configuration.
|
||||
///
|
||||
/// If `logger` is provided, stderr output from the LSP server will be written
|
||||
/// to its file for debugging purposes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn spawn_lsp_service(
|
||||
config: LspServerConfig,
|
||||
executor: Arc<Background>,
|
||||
logger: Option<SimpleLogger>,
|
||||
) -> Result<LspServiceInitializationResult> {
|
||||
let workspace_root = config.initial_workspace().to_path_buf();
|
||||
|
||||
let resolved = match config.command_and_params().await {
|
||||
Ok(resolved) => resolved,
|
||||
Err(e) => {
|
||||
if let Some(ref logger) = logger {
|
||||
logger.log(format!("[startup error] {e}"));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let transport = match transport::ProcessTransport::new(
|
||||
resolved.command,
|
||||
executor.clone(),
|
||||
logger.clone(),
|
||||
) {
|
||||
Ok(transport) => transport,
|
||||
Err(e) => {
|
||||
if let Some(ref logger) = logger {
|
||||
logger.log(format!("[startup error] {e}"));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let jsonrpc_service = JsonRpcService::new(
|
||||
Box::new(transport),
|
||||
executor,
|
||||
lsp_types::error_codes::REQUEST_FAILED,
|
||||
);
|
||||
|
||||
let (notify_tx, notify_rx) = async_channel::unbounded::<ServerNotificationEvent>();
|
||||
let mut service = LspService::new(jsonrpc_service, notify_tx, workspace_root, logger.clone())?;
|
||||
|
||||
if let Err(e) = service.initialize(resolved.params).await {
|
||||
if let Some(ref logger) = logger {
|
||||
logger.log(format!("[startup error] {e}"));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(LspServiceInitializationResult {
|
||||
service,
|
||||
channel: notify_rx,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn spawn_lsp_service(
|
||||
_config: LspServerConfig,
|
||||
_executor: Arc<Background>,
|
||||
_logger: Option<()>,
|
||||
) -> Result<LspServiceInitializationResult> {
|
||||
Err(anyhow::anyhow!("LSP is not supported in WASM environments"))
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.add_singleton_model(|_| LspManagerModel::new());
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::LanguageId, model::LanguageServerId, supported_servers::LSPServerType, LspEvent,
|
||||
LspServerConfig, LspServerModel,
|
||||
};
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LspManagerModelEvent {
|
||||
/// ServerStarted is fired when the server is successfully started and reports ready status.
|
||||
/// ServerStopped is fired when the server has completed its shutdown.
|
||||
/// Both are routed from individual LspServerModel events.
|
||||
ServerStarted(PathBuf),
|
||||
ServerStopped(PathBuf),
|
||||
/// ServerRemoved is fired when a server is removed from the manager.
|
||||
/// This happens when the user explicitly removes the server (e.g., from settings or footer menu).
|
||||
/// Subscribers should drop their references to the server model.
|
||||
/// Contains the workspace path, server type, and the unique server ID.
|
||||
ServerRemoved {
|
||||
workspace_root: PathBuf,
|
||||
server_type: LSPServerType,
|
||||
server_id: LanguageServerId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LspManagerModel {
|
||||
/// Map from workspace root path to server info
|
||||
servers: HashMap<PathBuf, Vec<ModelHandle<LspServerModel>>>,
|
||||
/// Map from external file paths to the LSP server that should handle them.
|
||||
/// This is populated when navigating to definitions in files outside the workspace.
|
||||
external_file_servers: HashMap<PathBuf, LanguageServerId>,
|
||||
}
|
||||
|
||||
impl LspManagerModel {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
servers: HashMap::new(),
|
||||
external_file_servers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an iterator over all workspace root paths that currently have an LSP server.
|
||||
pub fn workspace_roots(&self) -> impl Iterator<Item = &PathBuf> {
|
||||
self.servers.keys()
|
||||
}
|
||||
|
||||
/// Returns the server handles for a given workspace root path.
|
||||
pub fn servers_for_workspace(&self, path: &Path) -> Option<&Vec<ModelHandle<LspServerModel>>> {
|
||||
self.servers.get(path)
|
||||
}
|
||||
|
||||
/// Returns true if a server of the given type is already registered for this workspace.
|
||||
/// This is used to prevent duplicate registrations.
|
||||
pub fn server_registered(
|
||||
&self,
|
||||
path: &Path,
|
||||
server_type: LSPServerType,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
let Some(servers) = self.servers.get(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for server in servers {
|
||||
if server.as_ref(ctx).server_type() == server_type {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn server_registered_and_started(
|
||||
&self,
|
||||
path: &Path,
|
||||
server_type: LSPServerType,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
let Some(servers) = self.servers.get(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for server in servers {
|
||||
if server.as_ref(ctx).server_type() == server_type {
|
||||
return server.as_ref(ctx).has_started();
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn server_for_path(
|
||||
&self,
|
||||
path: &Path,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ModelHandle<LspServerModel>> {
|
||||
// Resolve the language ID - early return if unknown
|
||||
let path_lang = LanguageId::from_path(path)?;
|
||||
|
||||
// First check if this is an external file that was registered via goto-definition
|
||||
if let Some(server_id) = self.external_file_servers.get(path) {
|
||||
if let Some(server) = self.server_by_id(*server_id, ctx) {
|
||||
// Validate that the server supports this file's language
|
||||
if server.as_ref(ctx).supports_language(&path_lang) {
|
||||
return Some(server);
|
||||
}
|
||||
log::debug!(
|
||||
"External file server for {} does not support language {:?}, falling back to workspace lookup",
|
||||
path.display(),
|
||||
path_lang
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Then try workspace-based lookup
|
||||
let lsp_model = self.lsp_model_for_path(path)?;
|
||||
|
||||
for server in lsp_model {
|
||||
let supported = server.as_ref(ctx).supports_language(&path_lang);
|
||||
|
||||
if supported {
|
||||
return Some(server.clone());
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!(
|
||||
"LSP server found for path: {}, but language does not match",
|
||||
path.display()
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Registers an external file (outside any workspace) to be handled by a specific LSP server.
|
||||
/// This is called when navigating to a definition in an external file.
|
||||
pub fn maybe_register_external_file(&mut self, path: &Path, server_id: LanguageServerId) {
|
||||
// Skip registration if the path is already under an existing workspace scope
|
||||
if self.lsp_model_for_path(path).is_some() {
|
||||
log::debug!(
|
||||
"Skipping external file registration for {} - already under workspace scope",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.external_file_servers
|
||||
.insert(path.to_path_buf(), server_id);
|
||||
}
|
||||
|
||||
/// Finds an LSP server by its unique ID.
|
||||
pub fn server_by_id(
|
||||
&self,
|
||||
id: LanguageServerId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ModelHandle<LspServerModel>> {
|
||||
self.servers
|
||||
.values()
|
||||
.flatten()
|
||||
.find(|server| server.as_ref(ctx).id() == id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Register a new LSP server at the given path.
|
||||
/// Returns false if a server of the same type is already registered for this workspace.
|
||||
pub fn register(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
config: LspServerConfig,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
// Check if a server of the same type is already registered for this workspace.
|
||||
if self.server_registered(&path, config.server_type(), ctx) {
|
||||
log::debug!(
|
||||
"LSP server {} already registered for path: {}",
|
||||
config.server_type().binary_name(),
|
||||
path.display()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
log::info!("Registering LSP server for path: {}", path.display());
|
||||
|
||||
let lsp = ctx.add_model(|_| LspServerModel::new(config));
|
||||
|
||||
let path_clone = path.clone();
|
||||
ctx.subscribe_to_model(&lsp, move |_, event, ctx| match event {
|
||||
LspEvent::Started => {
|
||||
ctx.emit(LspManagerModelEvent::ServerStarted(path_clone.clone()));
|
||||
}
|
||||
LspEvent::Stopped => {
|
||||
ctx.emit(LspManagerModelEvent::ServerStopped(path_clone.clone()));
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
self.servers.entry(path).or_default().push(lsp);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn start_all(&mut self, path: PathBuf, ctx: &mut ModelContext<Self>) {
|
||||
let Some(servers) = self.servers.get(&path) else {
|
||||
log::warn!(
|
||||
"No server registered for startup at path: {}",
|
||||
path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
for server in servers.iter() {
|
||||
// Skip servers that were manually stopped by the user
|
||||
if !server.as_ref(ctx).can_auto_start() {
|
||||
log::info!(
|
||||
"Skipping auto-start for manually stopped LSP server at path: {}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = server.update(ctx, |server, ctx| server.start(ctx));
|
||||
|
||||
if let Err(e) = &result {
|
||||
log::warn!(
|
||||
"Failed to start LSP server at path: {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_all(&mut self, path: PathBuf, ctx: &mut ModelContext<Self>) {
|
||||
let Some(servers) = self.servers.get(&path) else {
|
||||
log::warn!("No server resgistered to stop at path: {}", path.display());
|
||||
return;
|
||||
};
|
||||
|
||||
for server in servers {
|
||||
let result = server.update(ctx, |server, ctx| server.stop(false, ctx));
|
||||
|
||||
if let Err(e) = &result {
|
||||
log::warn!("Failed to stop LSP server at path: {}: {e}", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a specific LSP server from the manager.
|
||||
/// This stops the server and removes it from the internal HashMap.
|
||||
/// Emits a ServerRemoved event so subscribers can drop their references.
|
||||
pub fn remove_server(
|
||||
&mut self,
|
||||
workspace_root: &Path,
|
||||
server_type: LSPServerType,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(servers) = self.servers.get_mut(workspace_root) else {
|
||||
log::warn!(
|
||||
"No server registered to remove at path: {}",
|
||||
workspace_root.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Find and remove the server with matching type, capturing its ID first
|
||||
let mut removed_server_id: Option<LanguageServerId> = None;
|
||||
servers.retain(|server| {
|
||||
let server_ref = server.as_ref(ctx);
|
||||
if server_ref.server_type() == server_type {
|
||||
// Capture the server ID before removing
|
||||
removed_server_id = Some(server_ref.id());
|
||||
// Always attempt to stop the server before removing (manually_stopped = true).
|
||||
// The stop() method handles state checks internally.
|
||||
let _ = server.update(ctx, |s, ctx| s.stop(true, ctx));
|
||||
false // Remove from vec
|
||||
} else {
|
||||
true // Keep in vec
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up empty entries
|
||||
if servers.is_empty() {
|
||||
self.servers.remove(workspace_root);
|
||||
}
|
||||
|
||||
if let Some(server_id) = removed_server_id {
|
||||
log::info!(
|
||||
"Removed {} LSP server for {}",
|
||||
server_type.binary_name(),
|
||||
workspace_root.display()
|
||||
);
|
||||
ctx.emit(LspManagerModelEvent::ServerRemoved {
|
||||
workspace_root: workspace_root.to_path_buf(),
|
||||
server_type,
|
||||
server_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate all LSP servers for all workspaces.
|
||||
/// This should be called during app shutdown.
|
||||
pub fn terminate(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
log::info!(
|
||||
"Terminating all LSP servers for {} workspaces",
|
||||
self.servers.len()
|
||||
);
|
||||
let workspace_roots: Vec<_> = self.workspace_roots().cloned().collect();
|
||||
for root in workspace_roots {
|
||||
log::debug!(
|
||||
"Shutting down LSP servers for workspace: {}",
|
||||
root.display()
|
||||
);
|
||||
self.stop_all(root, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a path, return the path of the registered LSP workspace for that path, if any
|
||||
pub fn lsp_model_for_path(&self, path: &Path) -> Option<&[ModelHandle<LspServerModel>]> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(servers) = self.servers.get(ancestor) {
|
||||
return Some(servers);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn repo_path_for_path(_path: &Path, _ctx: &AppContext) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for LspManagerModel {
|
||||
type Event = LspManagerModelEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for LspManagerModel {}
|
||||
@@ -0,0 +1,748 @@
|
||||
use crate::{
|
||||
config::{lsp_uri_to_path, LanguageId},
|
||||
server_repo_watcher::LspRepoWatcher,
|
||||
supported_servers::LSPServerType,
|
||||
types::{
|
||||
DefinitionLocation, DocumentVersion, HoverResult, Location, ReferenceLocation,
|
||||
TextDocumentContentChangeEvent, TextEdit, WatchedFileChangeEvent,
|
||||
},
|
||||
LspServerConfig, LspServerLogLevel, LspService,
|
||||
};
|
||||
use instant::Instant;
|
||||
use lsp_types::{
|
||||
notification::{self, Notification},
|
||||
FormattingOptions, NumberOrString, ProgressParams, ProgressParamsValue,
|
||||
PublishDiagnosticsParams, WorkDoneProgress,
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{spawn_lsp_service, LspServiceInitializationResult};
|
||||
use anyhow::{Error, Result};
|
||||
use jsonrpc::ServerNotificationEvent;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use simple_logger::manager::LogManager;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use warp_core::features::FeatureFlag;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{r#async::executor::Background, Entity, ModelContext};
|
||||
|
||||
static NEXT_LANGUAGE_SERVER_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Unique identifier for a running language server instance.
|
||||
/// This is used to track which LSP server is associated with external files
|
||||
/// that were navigated to via goto-definition.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct LanguageServerId(usize);
|
||||
|
||||
impl LanguageServerId {
|
||||
pub fn new() -> Self {
|
||||
Self(NEXT_LANGUAGE_SERVER_ID.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LanguageServerId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub enum LspState {
|
||||
Stopped {
|
||||
manually_stopped: bool,
|
||||
},
|
||||
Starting,
|
||||
Stopping {
|
||||
manually_stopped: bool,
|
||||
},
|
||||
Available {
|
||||
service: Arc<LspService>,
|
||||
background_executor: Arc<Background>,
|
||||
},
|
||||
Failed {
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl LspState {
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Stopped { .. } => "stopped",
|
||||
Self::Starting => "starting",
|
||||
Self::Stopping { .. } => "stopping",
|
||||
Self::Available { .. } => "available",
|
||||
Self::Failed { .. } => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether this server can be auto-started.
|
||||
/// Returns false if the server was manually stopped by the user.
|
||||
pub fn can_auto_start(&self) -> bool {
|
||||
match self {
|
||||
Self::Stopped { manually_stopped } | Self::Stopping { manually_stopped } => {
|
||||
!manually_stopped
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LspServerModel {
|
||||
id: LanguageServerId,
|
||||
server_state: LspState,
|
||||
config: LspServerConfig,
|
||||
// This tracks all in-progress background tasks from the server.
|
||||
// Tasks are keyed by their progress token and removed when they finish.
|
||||
in_progress_tasks: HashMap<String, BackgroundTaskInfo>,
|
||||
diagnostics_by_path: HashMap<PathBuf, DocumentDiagnostics>,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) repo_watcher: LspRepoWatcher,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackgroundTaskInfo {
|
||||
pub task_token: String,
|
||||
pub message: Option<String>,
|
||||
pub finished: bool,
|
||||
pub updated_at: Instant,
|
||||
}
|
||||
|
||||
impl BackgroundTaskInfo {
|
||||
pub fn to_display_message(&self) -> String {
|
||||
let message_part = if let Some(message) = &self.message {
|
||||
format!("{} {}", self.task_token, message)
|
||||
} else {
|
||||
self.task_token.clone()
|
||||
};
|
||||
|
||||
if self.finished {
|
||||
format!("finished: {message_part}")
|
||||
} else {
|
||||
message_part
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DocumentDiagnostics {
|
||||
pub diagnostics: Vec<lsp_types::Diagnostic>,
|
||||
/// This is roundtripped back from the server to client.
|
||||
pub version: Option<i32>,
|
||||
pub published_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum LspEvent {
|
||||
Starting,
|
||||
BackgroundTaskUpdated,
|
||||
Idle,
|
||||
Stopped,
|
||||
Failed(Error),
|
||||
Started,
|
||||
DiagnosticsUpdated { path: PathBuf },
|
||||
}
|
||||
|
||||
// Determines whether to accept an incoming diagnostic update based on version.
|
||||
//
|
||||
// An incoming version of None means the server is sending diagnostics not tied to a
|
||||
// specific document version (e.g. transitive workspace updates from gopls). We accept
|
||||
// these so that stale diagnostics do not persist indefinitely. The caller is responsible
|
||||
// for preserving the existing version when the incoming version is None, so the
|
||||
// render-side version check can still filter out diagnostics that don't match the
|
||||
// current buffer.
|
||||
fn should_accept_publish_diagnostics_version(existing: Option<i32>, incoming: Option<i32>) -> bool {
|
||||
match (existing, incoming) {
|
||||
(Some(_), None) => true,
|
||||
(None, None) => true,
|
||||
(None, Some(_)) => true,
|
||||
(Some(existing), Some(incoming)) => incoming >= existing,
|
||||
}
|
||||
}
|
||||
|
||||
impl LspServerModel {
|
||||
pub(crate) fn new(config: LspServerConfig) -> Self {
|
||||
Self {
|
||||
id: LanguageServerId::new(),
|
||||
server_state: LspState::Stopped {
|
||||
manually_stopped: false,
|
||||
},
|
||||
config,
|
||||
in_progress_tasks: HashMap::new(),
|
||||
diagnostics_by_path: HashMap::new(),
|
||||
repo_watcher: LspRepoWatcher::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn repo_watcher_mut(&mut self) -> &mut LspRepoWatcher {
|
||||
&mut self.repo_watcher
|
||||
}
|
||||
|
||||
/// Returns the unique identifier for this language server instance.
|
||||
pub fn id(&self) -> LanguageServerId {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn server_type(&self) -> LSPServerType {
|
||||
self.config.server_type()
|
||||
}
|
||||
|
||||
pub fn server_name(&self) -> String {
|
||||
self.config.server_name()
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &LspState {
|
||||
&self.server_state
|
||||
}
|
||||
|
||||
pub fn log_to_server_log(&self, level: LspServerLogLevel, message: impl Into<String>) {
|
||||
if let LspState::Available { service, .. } = &self.server_state {
|
||||
service.log_to_server_log(level, message);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn latest_progress_update(&self) -> Option<&BackgroundTaskInfo> {
|
||||
self.in_progress_tasks
|
||||
.values()
|
||||
.max_by_key(|task| task.updated_at)
|
||||
}
|
||||
|
||||
pub fn is_ready_for_requests(&self) -> bool {
|
||||
matches!(&self.server_state, LspState::Available { .. })
|
||||
}
|
||||
|
||||
pub fn has_started(&self) -> bool {
|
||||
!matches!(&self.server_state, LspState::Stopped { .. })
|
||||
}
|
||||
|
||||
pub fn has_pending_tasks(&self) -> bool {
|
||||
!self.in_progress_tasks.is_empty()
|
||||
}
|
||||
|
||||
pub fn supports_language(&self, lang: &LanguageId) -> bool {
|
||||
self.config.languages().contains(lang)
|
||||
}
|
||||
|
||||
/// Returns the initial workspace path for this server.
|
||||
pub fn initial_workspace(&self) -> &Path {
|
||||
self.config.initial_workspace()
|
||||
}
|
||||
|
||||
/// Returns whether this server can be auto-started by LspManagerModel::start_all.
|
||||
/// Returns false if the server was manually stopped by the user.
|
||||
pub fn can_auto_start(&self) -> bool {
|
||||
self.server_state.can_auto_start()
|
||||
}
|
||||
|
||||
fn service(&self) -> Result<Arc<LspService>> {
|
||||
match &self.server_state {
|
||||
LspState::Available { service, .. } => Ok(service.clone()),
|
||||
LspState::Starting => Err(anyhow::anyhow!("Server is starting")),
|
||||
LspState::Stopped { .. } => Err(anyhow::anyhow!("Server is stopped")),
|
||||
LspState::Stopping { .. } => Err(anyhow::anyhow!("Server is stopping")),
|
||||
LspState::Failed { error } => Err(anyhow::anyhow!("Server has failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn start(&mut self, ctx: &mut ModelContext<Self>) -> Result<()> {
|
||||
match &self.server_state {
|
||||
LspState::Stopped { .. } => {
|
||||
self.server_state = LspState::Starting;
|
||||
ctx.emit(LspEvent::Starting);
|
||||
let server_name = self.server_name();
|
||||
|
||||
let config = self.config.clone();
|
||||
let executor = ctx.background_executor();
|
||||
let logger = match config.log_relative_path().cloned() {
|
||||
Some(log_relative_path) => {
|
||||
match LogManager::handle(ctx).update(ctx, |manager, _| {
|
||||
manager.register_namespace("lsp", true);
|
||||
manager.register("lsp", &log_relative_path, executor.clone())
|
||||
}) {
|
||||
Ok(logger) => Some(logger),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to register LSP log file for {server_name}; continuing without file logging: {e:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
ctx.spawn(
|
||||
async move { spawn_lsp_service(config, executor, logger).await },
|
||||
move |me, result, ctx| match result {
|
||||
Ok(LspServiceInitializationResult { service, channel }) => {
|
||||
ctx.spawn_stream_local(
|
||||
channel,
|
||||
|me, notification, ctx| {
|
||||
me.handle_server_notification(notification, ctx);
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
// At this point, the server has started and been initialized with its workspace folders
|
||||
// but it is likely still in the "bootstrapping" phase where it will respond to most
|
||||
// requests with `null` responses. We capture a reference to the service here
|
||||
// and set our state to available. We consider it "bootstrapped" when the server notifies us
|
||||
// that the bootstrap task is complete.
|
||||
me.server_state = LspState::Available {
|
||||
service: Arc::new(service),
|
||||
background_executor: ctx.background_executor(),
|
||||
};
|
||||
|
||||
if FeatureFlag::LSPAsATool.is_enabled() {
|
||||
me.repo_watcher.ensure(&me.config, ctx);
|
||||
}
|
||||
|
||||
ctx.emit(LspEvent::Started);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to start LSP server: {e}");
|
||||
let error = format!("{e:#}");
|
||||
me.server_state = LspState::Failed { error };
|
||||
ctx.emit(LspEvent::Failed(e));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
log::warn!(
|
||||
"Unable to start LSP server in state: {}",
|
||||
self.server_state.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn stop(&mut self, manually_stopped: bool, ctx: &mut ModelContext<Self>) -> Result<()> {
|
||||
match &self.server_state {
|
||||
LspState::Available { service, .. } => {
|
||||
if FeatureFlag::LSPAsATool.is_enabled() {
|
||||
self.repo_watcher.teardown(ctx);
|
||||
}
|
||||
|
||||
let service = service.clone();
|
||||
self.server_state = LspState::Stopping { manually_stopped };
|
||||
ctx.spawn(async move { service.shutdown().await }, |me, _, ctx| {
|
||||
// Only transition to Stopped if still in Stopping state.
|
||||
// This prevents race conditions if manual_start was called while shutdown was in flight.
|
||||
if let LspState::Stopping { manually_stopped } = me.server_state {
|
||||
me.server_state = LspState::Stopped { manually_stopped };
|
||||
ctx.emit(LspEvent::Stopped);
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
log::debug!(
|
||||
"Unable to stop LSP server in state: {}",
|
||||
self.server_state.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Manually starts the server and clears the manually_stopped flag.
|
||||
/// This should be called when the user explicitly wants to start the server.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn manual_start(&mut self, ctx: &mut ModelContext<Self>) -> Result<()> {
|
||||
match &self.server_state {
|
||||
LspState::Stopped { .. } | LspState::Failed { .. } => {
|
||||
// Clear the manually_stopped flag and start
|
||||
self.server_state = LspState::Stopped {
|
||||
manually_stopped: false,
|
||||
};
|
||||
self.start(ctx)
|
||||
}
|
||||
LspState::Stopping {
|
||||
manually_stopped: true,
|
||||
} => {
|
||||
// Server is still shutting down from a manual stop.
|
||||
// Clear the manually_stopped flag so can_auto_start returns true,
|
||||
// then use start_all to trigger start after shutdown completes.
|
||||
self.server_state = LspState::Stopping {
|
||||
manually_stopped: false,
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
log::debug!(
|
||||
"Unable to manually start LSP server in state: {}",
|
||||
self.server_state.name()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually starts the server (WASM stub).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn manual_start(&mut self, _ctx: &mut ModelContext<Self>) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Start is not supported in WASM environments"
|
||||
))
|
||||
}
|
||||
|
||||
/// Restarts the LSP server by stopping it and starting it again.
|
||||
/// The server will emit `LspEvent::Stopped` followed by `LspEvent::Started` on success.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn restart(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
log::info!("Restarting LSP server: {}", self.config.server_name());
|
||||
|
||||
match &self.server_state {
|
||||
LspState::Available { service, .. } => {
|
||||
if FeatureFlag::LSPAsATool.is_enabled() {
|
||||
self.repo_watcher.teardown(ctx);
|
||||
}
|
||||
|
||||
let service = service.clone();
|
||||
self.server_state = LspState::Stopping {
|
||||
manually_stopped: false,
|
||||
};
|
||||
ctx.spawn(async move { service.shutdown().await }, |me, _, ctx| {
|
||||
// Only transition if still in Stopping state
|
||||
if let LspState::Stopping { manually_stopped } = me.server_state {
|
||||
me.server_state = LspState::Stopped { manually_stopped };
|
||||
// Immediately start the server again
|
||||
if let Err(e) = me.start(ctx) {
|
||||
log::warn!("Failed to restart LSP server: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
LspState::Failed { .. } | LspState::Stopped { .. } => {
|
||||
// Server was in Failed or Stopped state, just start it
|
||||
self.server_state = LspState::Stopped {
|
||||
manually_stopped: false,
|
||||
};
|
||||
if let Err(e) = self.start(ctx) {
|
||||
log::warn!("Failed to restart LSP server: {e}");
|
||||
}
|
||||
}
|
||||
LspState::Starting | LspState::Stopping { .. } => {
|
||||
log::debug!(
|
||||
"Unable to restart LSP server in state: {}",
|
||||
self.server_state.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn restart(&mut self, _ctx: &mut ModelContext<Self>) {}
|
||||
|
||||
/// Different from stop -- on terminate, we won't update the server state and emit events based on server response.
|
||||
fn terminate(&mut self) {
|
||||
match &self.server_state {
|
||||
LspState::Available {
|
||||
service,
|
||||
background_executor,
|
||||
} => {
|
||||
let service = service.clone();
|
||||
let executor = background_executor.clone();
|
||||
// Assume the server state is stopped.
|
||||
self.server_state = LspState::Stopped {
|
||||
manually_stopped: false,
|
||||
};
|
||||
executor
|
||||
.spawn(async move {
|
||||
let _ = service.shutdown().await;
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
_ => {
|
||||
log::debug!(
|
||||
"Unable to stop LSP server in state: {}",
|
||||
self.server_state.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn start(&mut self, _ctx: &mut ModelContext<Self>) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"Start is not supported in WASM environments"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn stop(&mut self, _manually_stopped: bool, _ctx: &mut ModelContext<Self>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn document_is_open(&self, path: &PathBuf) -> Result<bool> {
|
||||
let service = self.service()?;
|
||||
service.text_document().document_is_open(path)
|
||||
}
|
||||
|
||||
/// Returns the last synced buffer version for the document, if it is open.
|
||||
pub fn last_synced_version(&self, path: &PathBuf) -> Result<Option<usize>> {
|
||||
let service = self.service()?;
|
||||
service.text_document().last_synced_version(path)
|
||||
}
|
||||
|
||||
pub fn did_open_document(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
content: String,
|
||||
initial_version: usize,
|
||||
) -> Result<impl Future<Output = Result<()>>> {
|
||||
let service = self.service()?;
|
||||
Ok(async move {
|
||||
service
|
||||
.text_document()
|
||||
.did_open(&path, content, initial_version)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn did_close_document(&self, path: PathBuf) -> Result<impl Future<Output = Result<()>>> {
|
||||
let service = self.service()?;
|
||||
Ok(async move { service.text_document().did_close(&path).await })
|
||||
}
|
||||
|
||||
pub fn did_change_document(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
version: DocumentVersion,
|
||||
deltas: Vec<TextDocumentContentChangeEvent>,
|
||||
) -> Result<impl Future<Output = Result<()>>> {
|
||||
let service = self.service()?;
|
||||
Ok(async move {
|
||||
service
|
||||
.text_document()
|
||||
.did_change(&path, version.as_i32(), deltas)
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn did_change_watched_files(&self, events: Vec<WatchedFileChangeEvent>) -> Result<()> {
|
||||
let service = self.service()?;
|
||||
service.workspace_watched_files_changed(events)
|
||||
}
|
||||
|
||||
pub fn goto_definition(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
position: Location,
|
||||
) -> Result<impl Future<Output = Result<Vec<DefinitionLocation>>>> {
|
||||
let service = self.service()?;
|
||||
Ok(async move {
|
||||
let result = service
|
||||
.text_document()
|
||||
.definition(&path, position.into_lsp())
|
||||
.await?;
|
||||
Ok(result
|
||||
.into_iter()
|
||||
.filter_map(|location| DefinitionLocation::try_from(location).ok())
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn handle_server_notification(
|
||||
&mut self,
|
||||
notification: ServerNotificationEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let ServerNotificationEvent { method, params } = notification;
|
||||
match method.as_str() {
|
||||
notification::Progress::METHOD => {
|
||||
if let Ok(progress_params) = serde_json::from_value::<ProgressParams>(params) {
|
||||
self.handle_progress_update(progress_params, ctx);
|
||||
}
|
||||
}
|
||||
notification::PublishDiagnostics::METHOD => {
|
||||
match serde_json::from_value::<PublishDiagnosticsParams>(params) {
|
||||
Ok(params) => self.handle_publish_diagnostics(params, ctx),
|
||||
Err(e) => log::warn!("Failed to parse PublishDiagnostics params: {e}"),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::warn!("Received unhandled notification {method}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
fn handle_progress_update(
|
||||
&mut self,
|
||||
progress_params: ProgressParams,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let task_token = match progress_params.token {
|
||||
NumberOrString::String(token) => token,
|
||||
NumberOrString::Number(token) => token.to_string(),
|
||||
};
|
||||
|
||||
let ProgressParamsValue::WorkDone(work_done_progress) = progress_params.value;
|
||||
|
||||
match work_done_progress {
|
||||
WorkDoneProgress::Begin(report) => {
|
||||
self.in_progress_tasks.insert(
|
||||
task_token.clone(),
|
||||
BackgroundTaskInfo {
|
||||
task_token,
|
||||
message: report.message.clone(),
|
||||
finished: false,
|
||||
updated_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
ctx.emit(LspEvent::BackgroundTaskUpdated);
|
||||
}
|
||||
WorkDoneProgress::Report(report) => {
|
||||
if let Some(task) = self.in_progress_tasks.get_mut(&task_token) {
|
||||
task.message = report.message.clone();
|
||||
task.updated_at = Instant::now();
|
||||
} else {
|
||||
// If we get a report without a begin, create the task
|
||||
self.in_progress_tasks.insert(
|
||||
task_token.clone(),
|
||||
BackgroundTaskInfo {
|
||||
task_token: task_token.clone(),
|
||||
message: report.message.clone(),
|
||||
finished: false,
|
||||
updated_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
ctx.emit(LspEvent::BackgroundTaskUpdated);
|
||||
}
|
||||
WorkDoneProgress::End(_) => {
|
||||
log::debug!("LSP server finished {task_token}");
|
||||
self.in_progress_tasks.remove(&task_token);
|
||||
ctx.emit(LspEvent::BackgroundTaskUpdated);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn handle_publish_diagnostics(
|
||||
&mut self,
|
||||
params: PublishDiagnosticsParams,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let uri = params.uri;
|
||||
|
||||
let path = match lsp_uri_to_path(&uri) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"PublishDiagnostics contained invalid URI {}: {e}",
|
||||
uri.as_str()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let existing_version = self
|
||||
.diagnostics_by_path
|
||||
.get(&path)
|
||||
.and_then(|diagnostics| diagnostics.version);
|
||||
|
||||
let incoming_version = params.version;
|
||||
let incoming_count = params.diagnostics.len();
|
||||
|
||||
if !should_accept_publish_diagnostics_version(existing_version, incoming_version) {
|
||||
self.log_to_server_log(
|
||||
LspServerLogLevel::Info,
|
||||
format!(
|
||||
"publishDiagnostics <- server: DROPPED file={} incoming_version={incoming_version:?} existing_version={existing_version:?} diag_count={incoming_count}",
|
||||
path.display()
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
self.log_to_server_log(
|
||||
LspServerLogLevel::Debug,
|
||||
format!(
|
||||
"publishDiagnostics <- server: ACCEPTED file={} version={incoming_version:?} diag_count={incoming_count}",
|
||||
path.display()
|
||||
),
|
||||
);
|
||||
|
||||
// When the incoming version is None (unversioned transitive update), preserve
|
||||
// the existing version so the render-side version check can still guard against
|
||||
// showing diagnostics that don't match the current buffer version.
|
||||
let stored_version = incoming_version.or(existing_version);
|
||||
|
||||
self.diagnostics_by_path.insert(
|
||||
path.clone(),
|
||||
DocumentDiagnostics {
|
||||
diagnostics: params.diagnostics,
|
||||
version: stored_version,
|
||||
published_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
ctx.emit(LspEvent::DiagnosticsUpdated { path });
|
||||
}
|
||||
|
||||
pub fn format_document(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
options: FormattingOptions,
|
||||
) -> Result<impl Future<Output = Result<Option<Vec<TextEdit>>>>> {
|
||||
let service = self.service()?;
|
||||
Ok(async move { service.text_document().format(&path, options).await })
|
||||
}
|
||||
|
||||
pub fn hover(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
position: Location,
|
||||
) -> Result<impl Future<Output = Result<Option<HoverResult>>>> {
|
||||
let service = self.service()?;
|
||||
Ok(async move {
|
||||
service
|
||||
.text_document()
|
||||
.hover(&path, position.into_lsp())
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub fn diagnostics_for_path(&self, path: &Path) -> Result<Option<&DocumentDiagnostics>> {
|
||||
Ok(self.diagnostics_by_path.get(path))
|
||||
}
|
||||
|
||||
pub fn find_references(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
position: Location,
|
||||
) -> Result<impl Future<Output = Result<Vec<ReferenceLocation>>>> {
|
||||
let service = self.service()?;
|
||||
Ok(async move {
|
||||
service
|
||||
.text_document()
|
||||
.references(&path, position.into_lsp())
|
||||
.await
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for LspServerModel {
|
||||
type Event = LspEvent;
|
||||
}
|
||||
|
||||
impl Drop for LspServerModel {
|
||||
fn drop(&mut self) {
|
||||
self.terminate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use std::{future::Future, path::PathBuf, pin::Pin};
|
||||
|
||||
use async_channel::Sender;
|
||||
use lsp_types::FileChangeType;
|
||||
use repo_metadata::{
|
||||
repository::{RepositorySubscriber, SubscriberId},
|
||||
DirectoryWatcher, Repository, RepositoryUpdate,
|
||||
};
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::{ModelContext, SingletonEntity, WeakModelHandle};
|
||||
|
||||
use crate::{model::LspServerModel, types::WatchedFileChangeEvent, LspServerConfig};
|
||||
|
||||
enum RepoWatchState {
|
||||
NotWatching,
|
||||
Starting {
|
||||
repository: WeakModelHandle<Repository>,
|
||||
subscriber_id: SubscriberId,
|
||||
},
|
||||
Watching {
|
||||
repository: WeakModelHandle<Repository>,
|
||||
subscriber_id: SubscriberId,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct LspRepoWatcher {
|
||||
state: RepoWatchState,
|
||||
}
|
||||
|
||||
impl LspRepoWatcher {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn teardown(&mut self, ctx: &mut ModelContext<LspServerModel>) {
|
||||
let old_state = std::mem::replace(&mut self.state, RepoWatchState::NotWatching);
|
||||
|
||||
match old_state {
|
||||
RepoWatchState::NotWatching => {}
|
||||
RepoWatchState::Starting {
|
||||
repository,
|
||||
subscriber_id,
|
||||
}
|
||||
| RepoWatchState::Watching {
|
||||
repository,
|
||||
subscriber_id,
|
||||
} => {
|
||||
if let Some(repository) = repository.upgrade(ctx) {
|
||||
repository.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(subscriber_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure(&mut self, config: &LspServerConfig, ctx: &mut ModelContext<LspServerModel>) {
|
||||
if !matches!(self.state, RepoWatchState::NotWatching) {
|
||||
return;
|
||||
}
|
||||
|
||||
let (tx, rx) = async_channel::unbounded::<RepositoryUpdate>();
|
||||
|
||||
let workspace_root: PathBuf = config.initial_workspace().to_path_buf();
|
||||
let workspace_root_for_log = workspace_root.display().to_string();
|
||||
|
||||
let repository = DirectoryWatcher::handle(ctx).update(ctx, |watcher, ctx| {
|
||||
let Ok(standardized) = StandardizedPath::from_local_canonicalized(&workspace_root)
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
watcher.add_directory(standardized, ctx).ok()
|
||||
});
|
||||
|
||||
let Some(repository) = repository else {
|
||||
log::warn!(
|
||||
"Unable to find or watch directory for LSP workspace: {workspace_root_for_log}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let start = repository.update(ctx, |repo, ctx| {
|
||||
repo.start_watching(Box::new(LspRepoSubscriber { tx }), ctx)
|
||||
});
|
||||
|
||||
let repository_for_spawn = repository.downgrade();
|
||||
let subscriber_id = start.subscriber_id;
|
||||
|
||||
self.state = RepoWatchState::Starting {
|
||||
repository: repository_for_spawn.clone(),
|
||||
subscriber_id,
|
||||
};
|
||||
|
||||
ctx.spawn(start.registration_future, move |me, res, ctx| match res {
|
||||
Ok(()) => {
|
||||
if matches!(
|
||||
me.repo_watcher_mut().state,
|
||||
RepoWatchState::Starting { subscriber_id: s, .. } if s == subscriber_id
|
||||
) {
|
||||
me.repo_watcher_mut().state = RepoWatchState::Watching {
|
||||
repository: repository_for_spawn.clone(),
|
||||
subscriber_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
if matches!(
|
||||
me.repo_watcher_mut().state,
|
||||
RepoWatchState::Starting { subscriber_id: s, .. } if s == subscriber_id
|
||||
) {
|
||||
me.repo_watcher_mut().state = RepoWatchState::NotWatching;
|
||||
}
|
||||
|
||||
log::warn!("Unable to start LSP server: {err}");
|
||||
if let Some(repository) = repository_for_spawn.upgrade(ctx) {
|
||||
repository.update(ctx, |repo, ctx| {
|
||||
repo.stop_watching(subscriber_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ctx.spawn_stream_local(
|
||||
rx,
|
||||
|me, update, _ctx| {
|
||||
let mut events = Vec::new();
|
||||
|
||||
events.extend(update.added.into_iter().map(|file| WatchedFileChangeEvent {
|
||||
path: file.path,
|
||||
typ: FileChangeType::CREATED,
|
||||
}));
|
||||
|
||||
events.extend(
|
||||
update
|
||||
.modified
|
||||
.into_iter()
|
||||
.map(|file| WatchedFileChangeEvent {
|
||||
path: file.path,
|
||||
typ: FileChangeType::CHANGED,
|
||||
}),
|
||||
);
|
||||
|
||||
events.extend(
|
||||
update
|
||||
.deleted
|
||||
.into_iter()
|
||||
.map(|file| WatchedFileChangeEvent {
|
||||
path: file.path,
|
||||
typ: FileChangeType::DELETED,
|
||||
}),
|
||||
);
|
||||
|
||||
for (to, from) in update.moved {
|
||||
events.push(WatchedFileChangeEvent {
|
||||
path: to.path,
|
||||
typ: FileChangeType::CREATED,
|
||||
});
|
||||
|
||||
events.push(WatchedFileChangeEvent {
|
||||
path: from.path,
|
||||
typ: FileChangeType::DELETED,
|
||||
});
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = me.did_change_watched_files(events) {
|
||||
log::warn!("Failed to send didChangeWatchedFiles notification: {e}");
|
||||
}
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LspRepoWatcher {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: RepoWatchState::NotWatching,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LspRepoSubscriber {
|
||||
tx: Sender<RepositoryUpdate>,
|
||||
}
|
||||
|
||||
impl RepositorySubscriber for LspRepoSubscriber {
|
||||
fn on_scan(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
|
||||
fn on_files_updated(
|
||||
&mut self,
|
||||
_repository: &Repository,
|
||||
update: &RepositoryUpdate,
|
||||
_ctx: &mut ModelContext<Repository>,
|
||||
) -> Pin<Box<dyn Future<Output = ()> + Send + 'static>> {
|
||||
let tx = self.tx.clone();
|
||||
let update = update.clone();
|
||||
Box::pin(async move {
|
||||
let _ = tx.send(update).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LspRepoWatcher;
|
||||
|
||||
impl LspRepoWatcher {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
use std::path::Path;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use command::r#async::Command;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::install::{
|
||||
fetch_latest_metadata_from_github_dynamic_asset, install_from_github, AssetKind,
|
||||
};
|
||||
use crate::language_server_candidate::{LanguageServerCandidate, LanguageServerMetadata};
|
||||
use crate::CommandBuilder;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
const SERVER_NAME: &str = "clangd";
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub struct ClangdCandidate {
|
||||
client: Arc<http_client::Client>,
|
||||
}
|
||||
|
||||
impl ClangdCandidate {
|
||||
pub fn new(client: Arc<http_client::Client>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn find_installed_binary_in_data_dir() -> Option<PathBuf> {
|
||||
let install_root = warp_core::paths::data_dir().join(SERVER_NAME);
|
||||
if !install_root.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(&install_root) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let version_dir = entry.path();
|
||||
if !version_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(binary_path) = find_binary_in_dir(&version_dir) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if binary_is_working(&binary_path).await {
|
||||
return Some(binary_path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn asset_os_suffix() -> anyhow::Result<&'static str> {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("macos", _) => Ok("mac"),
|
||||
("linux", "x86_64") => Ok("linux"),
|
||||
("windows", "x86_64") => Ok("windows"),
|
||||
(os, arch) => anyhow::bail!("Unsupported platform for clangd: {os}/{arch}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn is_c_or_cpp_extension(extension: &str) -> bool {
|
||||
matches!(
|
||||
extension,
|
||||
"c" | "C" | "cc" | "cpp" | "cxx" | "h" | "hh" | "hpp" | "hxx" | "H"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
async fn binary_is_working(binary_path: &Path) -> bool {
|
||||
let mut command = Command::new(binary_path);
|
||||
command.arg("--version");
|
||||
command
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn is_bin_clangd_path(path: &Path) -> bool {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let expected_name = if cfg!(windows) {
|
||||
"clangd.exe"
|
||||
} else {
|
||||
"clangd"
|
||||
};
|
||||
|
||||
if file_name != expected_name {
|
||||
return false;
|
||||
}
|
||||
|
||||
path.parent()
|
||||
.and_then(|parent| parent.file_name())
|
||||
.and_then(|name| name.to_str())
|
||||
== Some("bin")
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn find_binary_in_dir(root: &Path) -> Option<PathBuf> {
|
||||
let mut directories = vec![root.to_path_buf()];
|
||||
|
||||
while let Some(dir) = directories.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
directories.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_bin_clangd_path(&path) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl LanguageServerCandidate for ClangdCandidate {
|
||||
async fn should_suggest_for_repo(&self, path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
let repo_markers = [
|
||||
"compile_commands.json",
|
||||
"compile_flags.txt",
|
||||
".clangd",
|
||||
"CMakeLists.txt",
|
||||
];
|
||||
|
||||
if repo_markers.iter().any(|marker| path.join(marker).exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
entries.flatten().any(|entry| {
|
||||
let file_path = entry.path();
|
||||
file_path.is_file()
|
||||
&& file_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(is_c_or_cpp_extension)
|
||||
})
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool {
|
||||
Self::find_installed_binary_in_data_dir().await.is_some()
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, executor: &CommandBuilder) -> bool {
|
||||
executor
|
||||
.command(SERVER_NAME)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
metadata: LanguageServerMetadata,
|
||||
_executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
let binary_path = install_from_github(
|
||||
&self.client,
|
||||
&metadata,
|
||||
SERVER_NAME,
|
||||
AssetKind::Zip,
|
||||
Some(find_binary_in_dir),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Verify the installed binary works
|
||||
if !binary_is_working(&binary_path).await {
|
||||
anyhow::bail!(
|
||||
"Installed clangd binary at {} failed version check",
|
||||
binary_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
let os_suffix = asset_os_suffix()?;
|
||||
|
||||
fetch_latest_metadata_from_github_dynamic_asset(
|
||||
&self.client,
|
||||
"clangd",
|
||||
"clangd",
|
||||
move |tag| format!("clangd-{os_suffix}-{tag}.zip"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
impl LanguageServerCandidate for ClangdCandidate {
|
||||
async fn should_suggest_for_repo(&self, _path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
_metadata: LanguageServerMetadata,
|
||||
_executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::language_server_candidate::{LanguageServerCandidate, LanguageServerMetadata};
|
||||
use crate::CommandBuilder;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::install::fetch_latest_metadata_from_github;
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub struct GoPlsCandidate {
|
||||
client: Arc<http_client::Client>,
|
||||
}
|
||||
|
||||
impl GoPlsCandidate {
|
||||
pub fn new(client: Arc<http_client::Client>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl LanguageServerCandidate for GoPlsCandidate {
|
||||
async fn should_suggest_for_repo(&self, path: &Path, executor: &CommandBuilder) -> bool {
|
||||
if !path.join("go.mod").exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if Go is installed
|
||||
executor
|
||||
.command("go")
|
||||
.arg("version")
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool {
|
||||
// gopls doesn't support custom installation yet
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, executor: &CommandBuilder) -> bool {
|
||||
executor
|
||||
.command("gopls")
|
||||
.arg("version")
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
_metadata: LanguageServerMetadata,
|
||||
executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
let output = executor
|
||||
.command("go")
|
||||
.args(["install", "golang.org/x/tools/gopls@latest"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to install gopls: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
// gopls doesn't provide prebuilt binaries; it must be installed via `go install`
|
||||
fetch_latest_metadata_from_github(&self.client, "golang", "tools", None).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
impl LanguageServerCandidate for GoPlsCandidate {
|
||||
async fn should_suggest_for_repo(&self, _path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
_metadata: LanguageServerMetadata,
|
||||
_executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod clangd;
|
||||
pub mod go;
|
||||
pub mod pyright;
|
||||
pub mod rust;
|
||||
pub mod typescript_language_server;
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::language_server_candidate::{LanguageServerCandidate, LanguageServerMetadata};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::supported_servers::CustomBinaryConfig;
|
||||
use crate::CommandBuilder;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use anyhow::Context;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use command::r#async::Command;
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub struct PyrightCandidate {
|
||||
client: Arc<http_client::Client>,
|
||||
}
|
||||
|
||||
impl PyrightCandidate {
|
||||
/// Path to the langserver JS file relative to the pyright install directory.
|
||||
#[cfg(feature = "local_fs")]
|
||||
const LANGSERVER_JS_PATH: &str = "node_modules/pyright/langserver.index.js";
|
||||
|
||||
/// Path to the pyright CLI JS file (used for version checks).
|
||||
#[cfg(feature = "local_fs")]
|
||||
const PYRIGHT_CLI_PATH: &str = "node_modules/pyright/dist/pyright.js";
|
||||
|
||||
pub fn new(client: Arc<http_client::Client>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Finds the configuration for running pyright from our custom installation.
|
||||
///
|
||||
/// Instead of running the `pyright-langserver` wrapper script (which has a shebang
|
||||
/// requiring node in PATH), we run node directly with the langserver.index.js file.
|
||||
/// This is the same pattern used by Zed.
|
||||
///
|
||||
/// Pyright can be installed with either system node or our custom node. This function
|
||||
/// checks for both cases:
|
||||
/// 1. First tries our custom node installation
|
||||
/// 2. Falls back to system node if custom node isn't available
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path_env_var` - The PATH environment variable to use when checking for system node.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn find_installed_binary_config(
|
||||
path_env_var: Option<&str>,
|
||||
) -> Option<CustomBinaryConfig> {
|
||||
let install_dir = warp_core::paths::data_dir().join("pyright");
|
||||
let langserver_js = install_dir.join(Self::LANGSERVER_JS_PATH);
|
||||
|
||||
// Check if the JS file exists
|
||||
if !langserver_js.is_file() {
|
||||
log::info!(
|
||||
"Pyright langserver.index.js not found at {}",
|
||||
langserver_js.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try to find a working node binary - first custom, then system
|
||||
let node_binary = node_runtime::find_working_node_binary(path_env_var).await?;
|
||||
|
||||
// Verify the pyright installation works by running `node pyright.js --version`
|
||||
let pyright_cli = install_dir.join(Self::PYRIGHT_CLI_PATH);
|
||||
if pyright_cli.is_file() {
|
||||
let mut cmd = Command::new(&node_binary);
|
||||
// Propagate PATH so "node" (bare name) resolves when using system node
|
||||
if let Some(path) = path_env_var {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
cmd.arg(&pyright_cli).arg("--version");
|
||||
match cmd.output().await {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
log::info!("Verified pyright installation: {}", version.trim());
|
||||
}
|
||||
Ok(output) => {
|
||||
log::warn!(
|
||||
"Pyright version check failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to run pyright version check: {}", e);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"Pyright CLI not found at {}, skipping version check",
|
||||
pyright_cli.display()
|
||||
);
|
||||
// Still proceed - the langserver.index.js exists, installation might still work
|
||||
}
|
||||
|
||||
Some(CustomBinaryConfig {
|
||||
binary_path: node_binary,
|
||||
prepend_args: vec![langserver_js.to_string_lossy().to_string()],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl LanguageServerCandidate for PyrightCandidate {
|
||||
async fn should_suggest_for_repo(&self, path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
// Check for common Python project indicators
|
||||
path.join("pyproject.toml").exists()
|
||||
|| path.join("setup.py").exists()
|
||||
|| path.join("requirements.txt").exists()
|
||||
|| path.join("Pipfile").exists()
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, executor: &CommandBuilder) -> bool {
|
||||
Self::find_installed_binary_config(executor.path_env_var())
|
||||
.await
|
||||
.is_some()
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, executor: &CommandBuilder) -> bool {
|
||||
executor
|
||||
.command("pyright-langserver")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
metadata: LanguageServerMetadata,
|
||||
executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
log::info!("Installing pyright version {}", metadata.version);
|
||||
|
||||
let install_dir = warp_core::paths::data_dir().join("pyright");
|
||||
|
||||
// Create the installation directory
|
||||
async_fs::create_dir_all(&install_dir)
|
||||
.await
|
||||
.context("Failed to create pyright installation directory")?;
|
||||
|
||||
// First, check if system node is available and meets requirements
|
||||
let use_system_node = match executor.path_env_var() {
|
||||
Some(path) => node_runtime::detect_system_node(path).await.is_ok(),
|
||||
None => false,
|
||||
};
|
||||
|
||||
let custom_node_paths = if use_system_node {
|
||||
log::info!("Using system Node.js for pyright installation");
|
||||
None
|
||||
} else {
|
||||
log::info!("System Node.js not found or too old, installing custom Node.js");
|
||||
node_runtime::install_npm(&self.client).await?;
|
||||
Some((
|
||||
node_runtime::node_binary_path()?,
|
||||
node_runtime::npm_binary_path()?,
|
||||
))
|
||||
};
|
||||
|
||||
// Install pyright using npm
|
||||
log::info!("Installing pyright@{} using npm", metadata.version);
|
||||
|
||||
// Build the npm install command:
|
||||
// - System node: run `npm` directly (it's on PATH)
|
||||
// - Custom node: run `node <npm_path>` to avoid relying on shebang resolution
|
||||
let mut cmd = if let Some((node_path, npm_path)) = &custom_node_paths {
|
||||
let mut c = executor.command(node_path);
|
||||
c.arg(npm_path);
|
||||
c
|
||||
} else {
|
||||
executor.command("npm")
|
||||
};
|
||||
|
||||
cmd.arg("install")
|
||||
.arg("--ignore-scripts")
|
||||
.arg(format!("pyright@{}", metadata.version))
|
||||
.current_dir(&install_dir);
|
||||
|
||||
let output = cmd.output().await.context("Failed to run npm install")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Failed to install pyright via npm: {}", stderr);
|
||||
}
|
||||
|
||||
log::info!("Pyright installed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
let version = node_runtime::fetch_npm_package_version(&self.client, "pyright")
|
||||
.await
|
||||
.context("Failed to fetch pyright version from npm registry")?;
|
||||
|
||||
Ok(LanguageServerMetadata {
|
||||
version,
|
||||
url: None, // npm packages don't have direct download URLs
|
||||
digest: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
impl LanguageServerCandidate for PyrightCandidate {
|
||||
async fn should_suggest_for_repo(&self, _path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
_metadata: LanguageServerMetadata,
|
||||
_executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::install::{fetch_latest_metadata_from_github, install_from_github, AssetKind};
|
||||
use crate::language_server_candidate::{LanguageServerCandidate, LanguageServerMetadata};
|
||||
use crate::CommandBuilder;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub struct RustAnalyzerCandidate {
|
||||
client: Arc<http_client::Client>,
|
||||
}
|
||||
|
||||
/// Returns the rust-analyzer asset name for the current platform.
|
||||
///
|
||||
/// Asset names follow the pattern: rust-analyzer-{arch}-{vendor}-{os}.{ext}
|
||||
/// e.g. rust-analyzer-aarch64-apple-darwin.gz, rust-analyzer-x86_64-unknown-linux-gnu.gz
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn asset_name() -> &'static str {
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
"rust-analyzer-aarch64-apple-darwin.gz"
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
"rust-analyzer-x86_64-apple-darwin.gz"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
"rust-analyzer-x86_64-unknown-linux-gnu.gz"
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
|
||||
{
|
||||
"rust-analyzer-aarch64-unknown-linux-gnu.gz"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
"rust-analyzer-x86_64-pc-windows-msvc.zip"
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
{
|
||||
"rust-analyzer-aarch64-pc-windows-msvc.zip"
|
||||
}
|
||||
#[cfg(not(any(
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "macos", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "aarch64"),
|
||||
all(target_os = "windows", target_arch = "x86_64"),
|
||||
all(target_os = "windows", target_arch = "aarch64"),
|
||||
)))]
|
||||
{
|
||||
todo!("Unsupported platform for rust-analyzer")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
const SERVER_NAME: &str = "rust-analyzer";
|
||||
|
||||
impl RustAnalyzerCandidate {
|
||||
pub fn new(client: Arc<http_client::Client>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Finds the path to an installed rust-analyzer binary in the data directory.
|
||||
///
|
||||
/// Returns the path to the first working binary found (verified by running `--help`).
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn find_installed_binary_in_data_dir() -> Option<std::path::PathBuf> {
|
||||
use tokio::process::Command;
|
||||
|
||||
let install_dir = warp_core::paths::data_dir().join(SERVER_NAME);
|
||||
if !install_dir.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if any version directory contains a working binary
|
||||
let binary_name = if cfg!(windows) {
|
||||
format!("{}.exe", SERVER_NAME)
|
||||
} else {
|
||||
SERVER_NAME.to_string()
|
||||
};
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(&install_dir) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let binary_path = path.join(&binary_name);
|
||||
if binary_path.is_file() {
|
||||
// Verify the binary works by running --help
|
||||
let mut cmd = Command::new(&binary_path);
|
||||
cmd.arg("--help");
|
||||
if cmd
|
||||
.output()
|
||||
.await
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(binary_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl LanguageServerCandidate for RustAnalyzerCandidate {
|
||||
async fn should_suggest_for_repo(&self, path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
path.join("Cargo.toml").exists()
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool {
|
||||
Self::find_installed_binary_in_data_dir().await.is_some()
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, executor: &CommandBuilder) -> bool {
|
||||
executor
|
||||
.command(SERVER_NAME)
|
||||
.arg("--help")
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
metadata: LanguageServerMetadata,
|
||||
_executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
let asset_kind = AssetKind::from_filename(asset_name()).ok_or_else(|| {
|
||||
anyhow::anyhow!("Unsupported archive format for asset: {}", asset_name())
|
||||
})?;
|
||||
install_from_github(&self.client, &metadata, SERVER_NAME, asset_kind, None).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
fetch_latest_metadata_from_github(
|
||||
&self.client,
|
||||
"rust-lang",
|
||||
"rust-analyzer",
|
||||
Some(asset_name()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
impl LanguageServerCandidate for RustAnalyzerCandidate {
|
||||
async fn should_suggest_for_repo(&self, _path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
_metadata: LanguageServerMetadata,
|
||||
_executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::language_server_candidate::{LanguageServerCandidate, LanguageServerMetadata};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::supported_servers::CustomBinaryConfig;
|
||||
use crate::CommandBuilder;
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use anyhow::Context;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use command::r#async::Command;
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub struct TypeScriptLanguageServerCandidate {
|
||||
client: Arc<http_client::Client>,
|
||||
}
|
||||
|
||||
impl TypeScriptLanguageServerCandidate {
|
||||
/// Path to the new langserver JS file (v4.0.0+) relative to the install directory.
|
||||
#[cfg(feature = "local_fs")]
|
||||
const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs";
|
||||
|
||||
/// Path to the old langserver JS file (pre-4.0.0) relative to the install directory.
|
||||
#[cfg(feature = "local_fs")]
|
||||
const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js";
|
||||
|
||||
pub fn new(client: Arc<http_client::Client>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
/// Finds the configuration for running typescript-language-server from our custom installation.
|
||||
///
|
||||
/// Instead of running the wrapper script (which has a shebang requiring node in PATH),
|
||||
/// we run node directly with the CLI JS file. This is the same pattern used by Zed.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path_env_var` - The PATH environment variable to use when checking for system node.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub async fn find_installed_binary_config(
|
||||
path_env_var: Option<&str>,
|
||||
) -> Option<CustomBinaryConfig> {
|
||||
let install_dir = warp_core::paths::data_dir().join("typescript-language-server");
|
||||
|
||||
// Check for the JS file - prefer new path (cli.mjs) over old path (cli.js)
|
||||
let server_js = {
|
||||
let new_path = install_dir.join(Self::NEW_SERVER_PATH);
|
||||
if new_path.is_file() {
|
||||
new_path
|
||||
} else {
|
||||
let old_path = install_dir.join(Self::OLD_SERVER_PATH);
|
||||
if old_path.is_file() {
|
||||
old_path
|
||||
} else {
|
||||
log::info!(
|
||||
"typescript-language-server JS file not found at {} or {}",
|
||||
new_path.display(),
|
||||
old_path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Try to find a working node binary - first custom, then system
|
||||
let node_binary = node_runtime::find_working_node_binary(path_env_var).await?;
|
||||
|
||||
// Verify the installation works by running `node cli.mjs --version`
|
||||
let mut cmd = Command::new(&node_binary);
|
||||
// Propagate PATH so "node" (bare name) resolves when using system node
|
||||
if let Some(path) = path_env_var {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
cmd.arg(&server_js).arg("--version");
|
||||
match cmd.output().await {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout);
|
||||
log::info!(
|
||||
"Verified typescript-language-server installation: {}",
|
||||
version.trim()
|
||||
);
|
||||
}
|
||||
Ok(output) => {
|
||||
log::warn!(
|
||||
"typescript-language-server version check failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to run typescript-language-server version check: {}",
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(CustomBinaryConfig {
|
||||
binary_path: node_binary,
|
||||
prepend_args: vec![server_js.to_string_lossy().to_string()],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl LanguageServerCandidate for TypeScriptLanguageServerCandidate {
|
||||
async fn should_suggest_for_repo(&self, path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
// Check for common JavaScript/TypeScript project indicators
|
||||
path.join("package.json").exists()
|
||||
|| path.join("tsconfig.json").exists()
|
||||
|| path.join("jsconfig.json").exists()
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, executor: &CommandBuilder) -> bool {
|
||||
Self::find_installed_binary_config(executor.path_env_var())
|
||||
.await
|
||||
.is_some()
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, executor: &CommandBuilder) -> bool {
|
||||
executor
|
||||
.command("typescript-language-server")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
metadata: LanguageServerMetadata,
|
||||
executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
log::info!(
|
||||
"Installing typescript-language-server version {}",
|
||||
metadata.version
|
||||
);
|
||||
|
||||
let install_dir = warp_core::paths::data_dir().join("typescript-language-server");
|
||||
|
||||
// Create the installation directory
|
||||
async_fs::create_dir_all(&install_dir)
|
||||
.await
|
||||
.context("Failed to create typescript-language-server installation directory")?;
|
||||
|
||||
// First, check if system node is available and meets requirements
|
||||
let use_system_node = match executor.path_env_var() {
|
||||
Some(path) => node_runtime::detect_system_node(path).await.is_ok(),
|
||||
None => false,
|
||||
};
|
||||
|
||||
let custom_node_paths = if use_system_node {
|
||||
log::info!("Using system Node.js for typescript-language-server installation");
|
||||
None
|
||||
} else {
|
||||
log::info!("System Node.js not found or too old, installing custom Node.js");
|
||||
node_runtime::install_npm(&self.client).await?;
|
||||
Some((
|
||||
node_runtime::node_binary_path()?,
|
||||
node_runtime::npm_binary_path()?,
|
||||
))
|
||||
};
|
||||
|
||||
// Install typescript-language-server and typescript using npm
|
||||
// typescript is a peer dependency required for the language server to work
|
||||
log::info!(
|
||||
"Installing typescript-language-server@{} using npm",
|
||||
metadata.version
|
||||
);
|
||||
|
||||
// Build the npm install command:
|
||||
// - System node: run `npm` directly (it's on PATH)
|
||||
// - Custom node: run `node <npm_path>` to avoid relying on shebang resolution
|
||||
let mut cmd = if let Some((node_path, npm_path)) = &custom_node_paths {
|
||||
let mut c = executor.command(node_path);
|
||||
c.arg(npm_path);
|
||||
c
|
||||
} else {
|
||||
executor.command("npm")
|
||||
};
|
||||
|
||||
cmd.arg("install")
|
||||
.arg("--ignore-scripts")
|
||||
.arg(format!("typescript-language-server@{}", metadata.version))
|
||||
.arg("typescript")
|
||||
.current_dir(&install_dir);
|
||||
|
||||
let output = cmd.output().await.context("Failed to run npm install")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!(
|
||||
"Failed to install typescript-language-server via npm: {}",
|
||||
stderr
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("typescript-language-server installed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
let version =
|
||||
node_runtime::fetch_npm_package_version(&self.client, "typescript-language-server")
|
||||
.await
|
||||
.context("Failed to fetch typescript-language-server version from npm registry")?;
|
||||
|
||||
Ok(LanguageServerMetadata {
|
||||
version,
|
||||
url: None, // npm packages don't have direct download URLs
|
||||
digest: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
impl LanguageServerCandidate for TypeScriptLanguageServerCandidate {
|
||||
async fn should_suggest_for_repo(&self, _path: &Path, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_installed_on_path(&self, _executor: &CommandBuilder) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn install(
|
||||
&self,
|
||||
_metadata: LanguageServerMetadata,
|
||||
_executor: &CommandBuilder,
|
||||
) -> anyhow::Result<()> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_metadata(&self) -> anyhow::Result<LanguageServerMetadata> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{lsp_uri_to_path, path_to_lsp_uri, LanguageId},
|
||||
types::{
|
||||
HoverResult, LspDefinitionLocation, ReferenceLocation, TextDocumentContentChangeEvent,
|
||||
TextEdit, WatchedFileChangeEvent,
|
||||
},
|
||||
LspServerLogLevel,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use globset::{Glob, GlobMatcher};
|
||||
use jsonrpc::{JsonRpcService, RequestId, ServerNotificationEvent};
|
||||
use lsp_types::{
|
||||
notification::{self, Notification},
|
||||
request::{self, Request},
|
||||
CancelParams, DidChangeTextDocumentParams, DidChangeWatchedFilesParams,
|
||||
DidChangeWatchedFilesRegistrationOptions, DidCloseTextDocumentParams,
|
||||
DidOpenTextDocumentParams, DocumentFormattingParams, FileChangeType, FileSystemWatcher,
|
||||
FormattingOptions, GlobPattern, GotoDefinitionParams, GotoDefinitionResponse, HoverParams,
|
||||
InitializeParams, InitializedParams, NumberOrString, OneOf, Position, ReferenceParams,
|
||||
RegistrationParams, RelativePattern, TextDocumentIdentifier, TextDocumentItem,
|
||||
TextDocumentPositionParams, UnregistrationParams, VersionedTextDocumentIdentifier, WatchKind,
|
||||
};
|
||||
use serde_json::Value;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use simple_logger::SimpleLogger;
|
||||
use warp_util::on_cancel::OnCancelFutureExt;
|
||||
|
||||
/// Tracks the sync state for an open document.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DocumentSyncState {
|
||||
/// The last buffer version that was successfully synced with the LSP server.
|
||||
/// None means the document was opened but no subsequent changes have been synced yet.
|
||||
pub last_synced_version: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct LspService {
|
||||
jsonrpc_service: JsonRpcService,
|
||||
server_capabilities: Option<lsp_types::ServerCapabilities>,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
open_documents: Arc<Mutex<HashMap<PathBuf, DocumentSyncState>>>,
|
||||
watched_files_registry: Arc<Mutex<WatchedFilesRegistry>>,
|
||||
notify_tx: async_channel::Sender<ServerNotificationEvent>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
logger: Option<SimpleLogger>,
|
||||
}
|
||||
|
||||
struct LspServerRequestHandler {
|
||||
watched_files_registry: Arc<Mutex<WatchedFilesRegistry>>,
|
||||
}
|
||||
|
||||
impl LspServerRequestHandler {
|
||||
fn handle_request(&self, method: &str, params: Value, id: RequestId) -> Result<()> {
|
||||
match method {
|
||||
"client/registerCapability" => {
|
||||
let params = match serde_json::from_value::<RegistrationParams>(params) {
|
||||
Ok(params) => params,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to parse client/registerCapability params (id: {id:?}): {e}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
for registration in params.registrations {
|
||||
if registration.method != notification::DidChangeWatchedFiles::METHOD {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(register_options) = registration.register_options else {
|
||||
log::debug!(
|
||||
"Ignoring didChangeWatchedFiles registration without options (id: {})",
|
||||
registration.id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let options = match serde_json::from_value::<
|
||||
DidChangeWatchedFilesRegistrationOptions,
|
||||
>(register_options)
|
||||
{
|
||||
Ok(options) => options,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to parse didChangeWatchedFiles registration options (id: {}): {e}",
|
||||
registration.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut registry = self
|
||||
.watched_files_registry
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
registry.register(registration.id, options);
|
||||
}
|
||||
}
|
||||
"client/unregisterCapability" => {
|
||||
let params = match serde_json::from_value::<UnregistrationParams>(params) {
|
||||
Ok(params) => params,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to parse client/unregisterCapability params (id: {id:?}): {e}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let mut registry = self
|
||||
.watched_files_registry
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
for unregistration in params.unregisterations {
|
||||
if unregistration.method == notification::DidChangeWatchedFiles::METHOD {
|
||||
registry.unregister(&unregistration.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LspService {
|
||||
/// Creates a new LspService with the given JsonRpcService.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn new(
|
||||
jsonrpc_service: JsonRpcService,
|
||||
notify_tx: async_channel::Sender<ServerNotificationEvent>,
|
||||
workspace_root: PathBuf,
|
||||
#[cfg(not(target_arch = "wasm32"))] logger: Option<SimpleLogger>,
|
||||
) -> Result<Self> {
|
||||
let watched_files_registry =
|
||||
Arc::new(Mutex::new(WatchedFilesRegistry::new(workspace_root)));
|
||||
|
||||
let server_request_handler = Arc::new(LspServerRequestHandler {
|
||||
watched_files_registry: watched_files_registry.clone(),
|
||||
});
|
||||
|
||||
let server_request_handler_for_closure = server_request_handler.clone();
|
||||
jsonrpc_service.set_server_request_handler(move |method, params, id| {
|
||||
server_request_handler_for_closure.handle_request(&method, params, id)
|
||||
});
|
||||
|
||||
let service = Self {
|
||||
jsonrpc_service,
|
||||
server_capabilities: None,
|
||||
open_documents: Arc::new(Mutex::new(HashMap::new())),
|
||||
watched_files_registry,
|
||||
notify_tx,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
logger,
|
||||
};
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) async fn initialize(&mut self, params: InitializeParams) -> Result<()> {
|
||||
let response = self.send_request::<request::Initialize>(params).await?;
|
||||
self.server_capabilities = Some(response.capabilities);
|
||||
|
||||
self.send_notification::<notification::Initialized>(InitializedParams {})?;
|
||||
self.subscribe::<notification::Progress>().await;
|
||||
self.subscribe::<notification::PublishDiagnostics>().await;
|
||||
|
||||
log::info!("LSP initialized successfully and will now run startup tasks");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn subscribe<N: notification::Notification>(&self) {
|
||||
self.jsonrpc_service
|
||||
.subscribe(N::METHOD.to_string(), self.notify_tx.clone())
|
||||
.await;
|
||||
}
|
||||
|
||||
pub fn server_capabilities(&self) -> &Option<lsp_types::ServerCapabilities> {
|
||||
&self.server_capabilities
|
||||
}
|
||||
|
||||
pub fn log_to_server_log(&self, level: LspServerLogLevel, message: impl Into<String>) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(logger) = &self.logger {
|
||||
logger.log(format!("[{level}] {}", message.into()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (level, message.into());
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) -> anyhow::Result<()> {
|
||||
// Send LSP shutdown request first
|
||||
log::debug!("Sending LSP shutdown request");
|
||||
match self.send_request::<request::Shutdown>(()).await {
|
||||
Ok(_) => log::debug!("LSP shutdown request completed successfully"),
|
||||
Err(e) => {
|
||||
log::warn!("LSP shutdown request failed: {e}");
|
||||
// Continue with exit notification even if shutdown request fails
|
||||
}
|
||||
}
|
||||
|
||||
// Send exit notification
|
||||
log::debug!("Sending LSP exit notification");
|
||||
if let Err(e) = self.send_notification::<notification::Exit>(()) {
|
||||
log::warn!("Failed to send LSP exit notification: {e}");
|
||||
}
|
||||
|
||||
// Finally, shutdown the transport (kill process if needed)
|
||||
self.jsonrpc_service
|
||||
.shutdown(std::time::Duration::from_secs(5))
|
||||
.await?;
|
||||
|
||||
log::debug!("LSP shutdown sequence completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a handle to text-document related operations and requests
|
||||
pub fn text_document(&self) -> TextDocumentService<'_> {
|
||||
TextDocumentService {
|
||||
service: self,
|
||||
open_documents: self.open_documents.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace_watched_files_changed(
|
||||
&self,
|
||||
events: Vec<WatchedFileChangeEvent>,
|
||||
) -> Result<()> {
|
||||
if events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let watched_files_registry = self
|
||||
.watched_files_registry
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to acquire lock on watched files registry"))?;
|
||||
|
||||
// If the server hasn't registered any watchers yet, don't send notifications.
|
||||
if watched_files_registry.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut changes = Vec::new();
|
||||
for event in events {
|
||||
if !watched_files_registry.matches(&event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match event.into_lsp() {
|
||||
Ok(event) => changes.push(event),
|
||||
Err(e) => log::warn!("Failed to convert file event: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
drop(watched_files_registry);
|
||||
|
||||
if changes.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.send_notification::<notification::DidChangeWatchedFiles>(DidChangeWatchedFilesParams {
|
||||
changes,
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_request_internal(&self, method: String, params: Value) -> Result<Value> {
|
||||
let request_id = self.jsonrpc_service.next_id();
|
||||
|
||||
let request = self
|
||||
.jsonrpc_service
|
||||
.send_request(request_id, method, params);
|
||||
|
||||
request
|
||||
.on_cancel(move || {
|
||||
self.cancel_request(request_id);
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn send_notification<N: Notification>(&self, params: N::Params) -> Result<()> {
|
||||
let params = serde_json::to_value(params)?;
|
||||
self.jsonrpc_service
|
||||
.send_notification(N::METHOD.to_string(), params)
|
||||
}
|
||||
|
||||
async fn send_request<R: Request>(&self, params: R::Params) -> Result<R::Result> {
|
||||
let params = serde_json::to_value(params)?;
|
||||
let request = self.send_request_internal(R::METHOD.to_string(), params);
|
||||
let response = request.await?;
|
||||
serde_json::from_value::<R::Result>(response)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse response: {e}"))
|
||||
}
|
||||
|
||||
fn cancel_request(&self, request_id: RequestId) {
|
||||
let cancel = serde_json::to_value(CancelParams {
|
||||
id: NumberOrString::Number(request_id),
|
||||
})
|
||||
.expect("Failed to serialize cancel params");
|
||||
|
||||
if let Err(e) = self
|
||||
.jsonrpc_service
|
||||
.send_notification(notification::Cancel::METHOD.to_string(), cancel)
|
||||
{
|
||||
log::error!("Failed to send cancel notification: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchedFilesRegistry {
|
||||
workspace_root: PathBuf,
|
||||
registrations: HashMap<String, Vec<GlobFileMatcher>>,
|
||||
}
|
||||
|
||||
impl WatchedFilesRegistry {
|
||||
fn new(workspace_root: PathBuf) -> Self {
|
||||
Self {
|
||||
workspace_root,
|
||||
registrations: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.registrations.is_empty()
|
||||
}
|
||||
|
||||
fn register(
|
||||
&mut self,
|
||||
registration_id: String,
|
||||
options: DidChangeWatchedFilesRegistrationOptions,
|
||||
) {
|
||||
let watchers = options
|
||||
.watchers
|
||||
.into_iter()
|
||||
.filter_map(|watcher| self.compile_watcher(watcher))
|
||||
.collect();
|
||||
|
||||
self.registrations.insert(registration_id, watchers);
|
||||
}
|
||||
|
||||
fn unregister(&mut self, registration_id: &str) {
|
||||
self.registrations.remove(registration_id);
|
||||
}
|
||||
|
||||
fn matches(&self, event: &WatchedFileChangeEvent) -> bool {
|
||||
let Ok(path_relative) = event.path.strip_prefix(&self.workspace_root) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let path_relative = warp_util::path::normalize_relative_path_for_glob(path_relative);
|
||||
|
||||
self.registrations
|
||||
.values()
|
||||
.flatten()
|
||||
.any(|watcher| watcher.matches(&path_relative, event.typ))
|
||||
}
|
||||
|
||||
fn compile_watcher(&self, watcher: FileSystemWatcher) -> Option<GlobFileMatcher> {
|
||||
let FileSystemWatcher { glob_pattern, kind } = watcher;
|
||||
|
||||
let pattern = self.pattern_for_glob_pattern(glob_pattern)?;
|
||||
|
||||
let matcher = match Glob::new(&pattern) {
|
||||
Ok(glob) => {
|
||||
let matcher: GlobMatcher = glob.compile_matcher();
|
||||
matcher
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to compile watched-files glob pattern {pattern:?}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(GlobFileMatcher { matcher, kind })
|
||||
}
|
||||
|
||||
fn pattern_for_glob_pattern(&self, glob_pattern: GlobPattern) -> Option<String> {
|
||||
match glob_pattern {
|
||||
GlobPattern::String(pattern) => Some(pattern),
|
||||
GlobPattern::Relative(relative_pattern) => {
|
||||
self.pattern_for_relative_pattern(relative_pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern_for_relative_pattern(&self, relative_pattern: RelativePattern) -> Option<String> {
|
||||
let RelativePattern { base_uri, pattern } = relative_pattern;
|
||||
|
||||
let base_path = match base_uri {
|
||||
OneOf::Left(folder) => folder.uri,
|
||||
OneOf::Right(uri) => uri,
|
||||
};
|
||||
|
||||
let base_path = match lsp_uri_to_path(&base_path) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
let base_uri = base_path.as_str();
|
||||
log::warn!("Failed to resolve relativePattern baseUri {base_uri}: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(base_relative) = base_path.strip_prefix(&self.workspace_root) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Normalize to forward slashes so glob patterns and event paths are comparable across platforms (esp. Windows).
|
||||
let prefix = warp_util::path::normalize_relative_path_for_glob(base_relative);
|
||||
|
||||
if prefix.is_empty() {
|
||||
Some(pattern)
|
||||
} else {
|
||||
Some(format!("{prefix}/{pattern}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GlobFileMatcher {
|
||||
matcher: GlobMatcher,
|
||||
kind: Option<WatchKind>,
|
||||
}
|
||||
|
||||
impl GlobFileMatcher {
|
||||
fn matches(&self, path_relative: &str, change_type: FileChangeType) -> bool {
|
||||
if let Some(kind) = self.kind {
|
||||
if let Some(required) = watch_kind_for_change_type(change_type) {
|
||||
if !kind.contains(required) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.matcher.is_match(path_relative)
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_kind_for_change_type(change_type: FileChangeType) -> Option<WatchKind> {
|
||||
match change_type {
|
||||
FileChangeType::CREATED => Some(WatchKind::Create),
|
||||
FileChangeType::CHANGED => Some(WatchKind::Change),
|
||||
FileChangeType::DELETED => Some(WatchKind::Delete),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Encapsulates text-document related operations and requests. This exists only for bookkeeping
|
||||
/// and discoverability.
|
||||
pub struct TextDocumentService<'a> {
|
||||
service: &'a LspService,
|
||||
open_documents: Arc<Mutex<HashMap<PathBuf, DocumentSyncState>>>,
|
||||
}
|
||||
|
||||
impl<'a> TextDocumentService<'a> {
|
||||
pub fn document_is_open(&self, path: &PathBuf) -> Result<bool> {
|
||||
let open_documents = self
|
||||
.open_documents
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?;
|
||||
Ok(open_documents.contains_key(path))
|
||||
}
|
||||
|
||||
/// Returns the last synced buffer version for the document, if it is open.
|
||||
/// Returns None if the document is not open.
|
||||
pub fn last_synced_version(&self, path: &PathBuf) -> Result<Option<usize>> {
|
||||
let open_documents = self
|
||||
.open_documents
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?;
|
||||
Ok(open_documents
|
||||
.get(path)
|
||||
.and_then(|state| state.last_synced_version))
|
||||
}
|
||||
|
||||
pub async fn did_open(
|
||||
&self,
|
||||
path: &Path,
|
||||
content: String,
|
||||
initial_version: usize,
|
||||
) -> Result<()> {
|
||||
{
|
||||
// Drop the guard before await
|
||||
let mut open_documents = self
|
||||
.open_documents
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?;
|
||||
// Use entry API to check if already present
|
||||
if open_documents.contains_key(&path.to_path_buf()) {
|
||||
return Ok(());
|
||||
}
|
||||
open_documents.insert(
|
||||
path.to_path_buf(),
|
||||
DocumentSyncState {
|
||||
last_synced_version: Some(initial_version),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
self.service.log_to_server_log(
|
||||
LspServerLogLevel::Debug,
|
||||
format!(
|
||||
"didOpen -> server: file={} version={initial_version}",
|
||||
path.display()
|
||||
),
|
||||
);
|
||||
|
||||
// Determine language ID from the file path
|
||||
let language_id = LanguageId::from_path(path)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not determine language ID for file: {}",
|
||||
path.display()
|
||||
)
|
||||
})?
|
||||
.lsp_language_identifier()
|
||||
.to_owned();
|
||||
|
||||
let did_open_params = DidOpenTextDocumentParams {
|
||||
text_document: TextDocumentItem {
|
||||
uri: path_to_lsp_uri(path)?,
|
||||
language_id,
|
||||
version: initial_version as i32,
|
||||
text: content,
|
||||
},
|
||||
};
|
||||
|
||||
self.service
|
||||
.send_notification::<notification::DidOpenTextDocument>(did_open_params)
|
||||
}
|
||||
|
||||
pub async fn did_close(&self, path: &Path) -> Result<()> {
|
||||
{
|
||||
// Drop the guard before await
|
||||
let mut open_documents = self
|
||||
.open_documents
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?;
|
||||
if open_documents.remove(&path.to_path_buf()).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let did_close_params = DidCloseTextDocumentParams {
|
||||
text_document: TextDocumentIdentifier {
|
||||
uri: path_to_lsp_uri(path)?,
|
||||
},
|
||||
};
|
||||
|
||||
self.service
|
||||
.send_notification::<notification::DidCloseTextDocument>(did_close_params)
|
||||
}
|
||||
|
||||
pub async fn did_change(
|
||||
&self,
|
||||
path: &Path,
|
||||
version: i32,
|
||||
deltas: Vec<TextDocumentContentChangeEvent>,
|
||||
) -> Result<()> {
|
||||
{
|
||||
// Check if document is open
|
||||
let mut open_documents = self
|
||||
.open_documents
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?;
|
||||
|
||||
let Some(state) = open_documents.get_mut(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
state.last_synced_version = Some(version as usize);
|
||||
}
|
||||
|
||||
let did_change_params = DidChangeTextDocumentParams {
|
||||
text_document: VersionedTextDocumentIdentifier {
|
||||
uri: path_to_lsp_uri(path)?,
|
||||
version,
|
||||
},
|
||||
content_changes: deltas.into_iter().map(|delta| delta.into_lsp()).collect(),
|
||||
};
|
||||
|
||||
self.service
|
||||
.send_notification::<notification::DidChangeTextDocument>(did_change_params)
|
||||
}
|
||||
|
||||
pub async fn definition(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> anyhow::Result<Vec<LspDefinitionLocation>> {
|
||||
let uri = path_to_lsp_uri(path)?;
|
||||
|
||||
let definition_params = GotoDefinitionParams {
|
||||
text_document_position_params: TextDocumentPositionParams {
|
||||
text_document: TextDocumentIdentifier { uri },
|
||||
position,
|
||||
},
|
||||
work_done_progress_params: Default::default(),
|
||||
partial_result_params: Default::default(),
|
||||
};
|
||||
|
||||
let result = self
|
||||
.service
|
||||
.send_request::<request::GotoDefinition>(definition_params)
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
self.service.log_to_server_log(
|
||||
LspServerLogLevel::Error,
|
||||
format!("textDocument/definition failed: {e}"),
|
||||
);
|
||||
}
|
||||
|
||||
// The LSP spec says textDocument/definition can return null when no definition is found
|
||||
// Handle this case explicitly since GotoDefinitionResponse doesn't deserialize null properly
|
||||
let Some(response) = result? else {
|
||||
return Err(anyhow::anyhow!("No definition found or LSP busy"));
|
||||
};
|
||||
|
||||
match response {
|
||||
GotoDefinitionResponse::Scalar(location) => Ok(vec![location.into()]),
|
||||
GotoDefinitionResponse::Array(locations) => {
|
||||
Ok(locations.into_iter().map(Into::into).collect())
|
||||
}
|
||||
GotoDefinitionResponse::Link(locations) => {
|
||||
Ok(locations.into_iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn format(
|
||||
&self,
|
||||
path: &Path,
|
||||
options: FormattingOptions,
|
||||
) -> anyhow::Result<Option<Vec<TextEdit>>> {
|
||||
let format_params = DocumentFormattingParams {
|
||||
text_document: TextDocumentIdentifier {
|
||||
uri: path_to_lsp_uri(path)?,
|
||||
},
|
||||
options,
|
||||
work_done_progress_params: Default::default(),
|
||||
};
|
||||
|
||||
let result = self
|
||||
.service
|
||||
.send_request::<request::Formatting>(format_params)
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
self.service.log_to_server_log(
|
||||
LspServerLogLevel::Error,
|
||||
format!("textDocument/formatting failed: {e}"),
|
||||
);
|
||||
}
|
||||
|
||||
// The LSP spec says textDocument/formatting can return null when formatting is not supported
|
||||
result.map(|edits_option| {
|
||||
edits_option.map(|text_edits| text_edits.into_iter().map(Into::into).collect())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn hover(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> anyhow::Result<Option<HoverResult>> {
|
||||
let uri = path_to_lsp_uri(path)?;
|
||||
|
||||
let hover_params = HoverParams {
|
||||
text_document_position_params: TextDocumentPositionParams {
|
||||
text_document: TextDocumentIdentifier { uri },
|
||||
position,
|
||||
},
|
||||
work_done_progress_params: Default::default(),
|
||||
};
|
||||
|
||||
let result = self
|
||||
.service
|
||||
.send_request::<request::HoverRequest>(hover_params)
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
self.service.log_to_server_log(
|
||||
LspServerLogLevel::Error,
|
||||
format!("textDocument/hover failed: {e}"),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result?.map(Into::into))
|
||||
}
|
||||
|
||||
pub async fn references(
|
||||
&self,
|
||||
path: &Path,
|
||||
position: Position,
|
||||
) -> anyhow::Result<Vec<ReferenceLocation>> {
|
||||
let uri = path_to_lsp_uri(path)?;
|
||||
|
||||
let reference_params = ReferenceParams {
|
||||
text_document_position: TextDocumentPositionParams {
|
||||
text_document: TextDocumentIdentifier { uri },
|
||||
position,
|
||||
},
|
||||
work_done_progress_params: Default::default(),
|
||||
partial_result_params: Default::default(),
|
||||
context: lsp_types::ReferenceContext {
|
||||
include_declaration: true,
|
||||
},
|
||||
};
|
||||
|
||||
let result = self
|
||||
.service
|
||||
.send_request::<request::References>(reference_params)
|
||||
.await;
|
||||
|
||||
if let Err(e) = &result {
|
||||
self.service.log_to_server_log(
|
||||
LspServerLogLevel::Error,
|
||||
format!("textDocument/references failed: {e}"),
|
||||
);
|
||||
}
|
||||
|
||||
// The LSP spec says textDocument/references can return null when no references are found
|
||||
Ok(result?
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|loc| ReferenceLocation::try_from(loc).ok())
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::servers::clangd::ClangdCandidate;
|
||||
use crate::servers::go::GoPlsCandidate;
|
||||
use crate::servers::pyright::PyrightCandidate;
|
||||
use crate::servers::rust::RustAnalyzerCandidate;
|
||||
use crate::servers::typescript_language_server::TypeScriptLanguageServerCandidate;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::CommandBuilder;
|
||||
use crate::{LanguageId, LanguageServerCandidate};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use command::r#async::Command;
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::PathBuf;
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::EnumIter;
|
||||
|
||||
/// Configuration for a custom LSP binary installation.
|
||||
///
|
||||
/// For most LSP servers, we just need the binary path. However, for Node.js-based
|
||||
/// servers like Pyright, we need to run `node langserver.index.js --stdio` instead
|
||||
/// of relying on the wrapper script (which has a shebang that requires node in PATH).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CustomBinaryConfig {
|
||||
/// The path to the executable (e.g., node binary or rust-analyzer binary)
|
||||
pub binary_path: PathBuf,
|
||||
/// Additional arguments to pass before any server-specific args (e.g., the JS file path)
|
||||
pub prepend_args: Vec<String>,
|
||||
}
|
||||
|
||||
/// Represents the different types of LSP servers supported by Warp.
|
||||
///
|
||||
/// This is also used in underlying sqlite type persistence. We should be careful
|
||||
/// not to rename an existing variant, as it will break persistence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
|
||||
pub enum LSPServerType {
|
||||
RustAnalyzer,
|
||||
GoPls,
|
||||
Pyright,
|
||||
TypeScriptLanguageServer,
|
||||
Clangd,
|
||||
}
|
||||
|
||||
/// Provides server-specific configuration for each LSP server type.
|
||||
impl LSPServerType {
|
||||
/// Creates a properly configured Command for this LSP server type.
|
||||
///
|
||||
/// Uses `CommandBuilder` to create the command, which ensures `.cmd`/`.bat`
|
||||
/// scripts are resolved on Windows and PATH is set correctly.
|
||||
///
|
||||
/// If a custom binary config is provided (e.g., from our data_dir installation),
|
||||
/// it will be used. Otherwise, falls back to the system PATH.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn create_command(
|
||||
&self,
|
||||
custom_config: Option<CustomBinaryConfig>,
|
||||
executor: &CommandBuilder,
|
||||
) -> Command {
|
||||
if let Some(config) = custom_config {
|
||||
let mut command = executor.command(&config.binary_path);
|
||||
command.args(&config.prepend_args);
|
||||
command.args(self.custom_install_args());
|
||||
command
|
||||
} else {
|
||||
let mut command = executor.command(self.binary_name());
|
||||
command.args(self.args());
|
||||
command
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the configuration for a custom-installed binary in the data directory.
|
||||
///
|
||||
/// This checks our custom installation location (`{data_dir}/{server_name}/`).
|
||||
/// For Node.js-based servers, this returns the node binary path plus the JS file as args.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path_env_var` - The PATH environment variable to use when checking for system dependencies
|
||||
/// (e.g., system node for pyright).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn find_installed_binary_config(
|
||||
&self,
|
||||
path_env_var: Option<&str>,
|
||||
) -> Option<CustomBinaryConfig> {
|
||||
match self {
|
||||
LSPServerType::RustAnalyzer => {
|
||||
RustAnalyzerCandidate::find_installed_binary_in_data_dir()
|
||||
.await
|
||||
.map(|path| CustomBinaryConfig {
|
||||
binary_path: path,
|
||||
prepend_args: vec![],
|
||||
})
|
||||
}
|
||||
LSPServerType::GoPls => {
|
||||
// gopls doesn't support custom installation yet
|
||||
None
|
||||
}
|
||||
LSPServerType::Pyright => {
|
||||
PyrightCandidate::find_installed_binary_config(path_env_var).await
|
||||
}
|
||||
LSPServerType::TypeScriptLanguageServer => {
|
||||
TypeScriptLanguageServerCandidate::find_installed_binary_config(path_env_var).await
|
||||
}
|
||||
LSPServerType::Clangd => ClangdCandidate::find_installed_binary_in_data_dir()
|
||||
.await
|
||||
.map(|path| CustomBinaryConfig {
|
||||
binary_path: path,
|
||||
prepend_args: vec![],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the binary works on the given PATH by running a version/help command.
|
||||
///
|
||||
/// Delegates to each server's candidate implementation.
|
||||
/// Returns true only if the binary executes successfully with exit code 0.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn is_working_on_path(
|
||||
&self,
|
||||
executor: &CommandBuilder,
|
||||
client: Arc<http_client::Client>,
|
||||
) -> bool {
|
||||
self.candidate(client).is_installed_on_path(executor).await
|
||||
}
|
||||
|
||||
pub fn binary_name(&self) -> &'static str {
|
||||
match self {
|
||||
LSPServerType::RustAnalyzer => "rust-analyzer",
|
||||
LSPServerType::GoPls => "gopls",
|
||||
LSPServerType::Pyright => "pyright-langserver",
|
||||
LSPServerType::TypeScriptLanguageServer => "typescript-language-server",
|
||||
LSPServerType::Clangd => "clangd",
|
||||
}
|
||||
}
|
||||
|
||||
/// Arguments for running via system PATH.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn args(&self) -> Vec<&'static str> {
|
||||
match self {
|
||||
LSPServerType::RustAnalyzer | LSPServerType::GoPls | LSPServerType::Clangd => vec![],
|
||||
LSPServerType::Pyright | LSPServerType::TypeScriptLanguageServer => vec!["--stdio"],
|
||||
}
|
||||
}
|
||||
|
||||
/// Arguments for running from a custom installation.
|
||||
/// These are added after any prepend_args from CustomBinaryConfig.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn custom_install_args(&self) -> Vec<&'static str> {
|
||||
match self {
|
||||
LSPServerType::RustAnalyzer => vec![],
|
||||
LSPServerType::GoPls => vec![],
|
||||
LSPServerType::Pyright => vec!["--stdio"],
|
||||
LSPServerType::TypeScriptLanguageServer => vec!["--stdio"],
|
||||
LSPServerType::Clangd => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the languages supported by this LSP server.
|
||||
pub fn languages(&self) -> Vec<LanguageId> {
|
||||
match self {
|
||||
LSPServerType::RustAnalyzer => vec![LanguageId::Rust],
|
||||
LSPServerType::GoPls => vec![LanguageId::Go],
|
||||
LSPServerType::Pyright => vec![LanguageId::Python],
|
||||
LSPServerType::TypeScriptLanguageServer => {
|
||||
vec![
|
||||
LanguageId::TypeScript,
|
||||
LanguageId::TypeScriptReact,
|
||||
LanguageId::JavaScript,
|
||||
LanguageId::JavaScriptReact,
|
||||
]
|
||||
}
|
||||
LSPServerType::Clangd => vec![LanguageId::C, LanguageId::Cpp],
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a display name for the languages supported by this server.
|
||||
/// For multi-language servers, returns "Language1/Language2".
|
||||
pub fn language_name(&self) -> String {
|
||||
match self {
|
||||
LSPServerType::TypeScriptLanguageServer => "TypeScript/JavaScript".to_string(),
|
||||
_ => self
|
||||
.languages()
|
||||
.iter()
|
||||
.map(|lang| {
|
||||
let id = lang.lsp_language_identifier();
|
||||
let mut chars = id.chars();
|
||||
// Capitalize the first character.
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
}
|
||||
})
|
||||
.join("/"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn candidate(&self, client: Arc<http_client::Client>) -> Box<dyn LanguageServerCandidate> {
|
||||
match self {
|
||||
LSPServerType::RustAnalyzer => Box::new(RustAnalyzerCandidate::new(client)),
|
||||
LSPServerType::GoPls => Box::new(GoPlsCandidate::new(client)),
|
||||
LSPServerType::Pyright => Box::new(PyrightCandidate::new(client)),
|
||||
LSPServerType::TypeScriptLanguageServer => {
|
||||
Box::new(TypeScriptLanguageServerCandidate::new(client))
|
||||
}
|
||||
LSPServerType::Clangd => Box::new(ClangdCandidate::new(client)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> impl Iterator<Item = LSPServerType> {
|
||||
LSPServerType::iter()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_process::{Child, ChildStdin, ChildStdout, Stdio};
|
||||
use async_trait::async_trait;
|
||||
use command::r#async::Command;
|
||||
use futures::lock::Mutex;
|
||||
use futures::{
|
||||
future::FutureExt,
|
||||
io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter},
|
||||
};
|
||||
use jsonrpc::Transport;
|
||||
use simple_logger::SimpleLogger;
|
||||
use warpui::r#async::{
|
||||
executor::{Background, BackgroundTask},
|
||||
Timer,
|
||||
};
|
||||
|
||||
/// Transport implementation for LSP communication over process stdin/stdout.
|
||||
/// Also manages the LSP server process lifecycle with graceful shutdown capabilities.
|
||||
#[derive(Clone)]
|
||||
pub struct ProcessTransport {
|
||||
input: Arc<Mutex<BufReader<ChildStdout>>>,
|
||||
output: Arc<Mutex<BufWriter<ChildStdin>>>,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
stderr_task: Arc<Mutex<Option<BackgroundTask>>>,
|
||||
}
|
||||
|
||||
impl ProcessTransport {
|
||||
/// Creates a new ProcessTransport.
|
||||
///
|
||||
/// If `logger` is provided, stderr output will be written to that logger's file
|
||||
/// in addition to being logged via `log::debug!`.
|
||||
pub fn new(
|
||||
mut command: Command,
|
||||
executor: Arc<Background>,
|
||||
logger: Option<SimpleLogger>,
|
||||
) -> anyhow::Result<Self> {
|
||||
command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to spawn process: {}", e))?;
|
||||
|
||||
let child_pid = child.id();
|
||||
log::info!("ProcessTransport: Spawned process with pid {child_pid}");
|
||||
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get child stdin"))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get child stdout"))?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get child stderr"))?;
|
||||
|
||||
// stderr -> logger background task
|
||||
let stderr_task = executor.spawn(async move {
|
||||
let mut reader = BufReader::new(stderr);
|
||||
let mut buffer = String::new();
|
||||
loop {
|
||||
buffer.clear();
|
||||
match reader.read_line(&mut buffer).await {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => {
|
||||
let message = buffer.trim_end();
|
||||
// Log to file if logger is available
|
||||
if let Some(ref logger) = logger {
|
||||
logger.log(format!("[stderr] {message}"));
|
||||
}
|
||||
// Also log via standard logging at debug level
|
||||
log::debug!("ProcessTransport [pid: {child_pid}] stderr: {message}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"ProcessTransport [pid: {child_pid}]: Error reading stderr: {e}"
|
||||
);
|
||||
if let Some(ref logger) = logger {
|
||||
logger.log(format!("[error] Error reading stderr: {e}"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
input: Arc::new(Mutex::new(BufReader::new(stdout))),
|
||||
output: Arc::new(Mutex::new(BufWriter::new(stdin))),
|
||||
child: Arc::new(Mutex::new(Some(child))),
|
||||
stderr_task: Arc::new(Mutex::new(Some(stderr_task))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Transport for ProcessTransport {
|
||||
async fn read(&self) -> anyhow::Result<String> {
|
||||
let mut content_length: Option<usize> = None;
|
||||
loop {
|
||||
let mut header_line = String::new();
|
||||
let bytes_read = {
|
||||
let mut reader = self.input.lock().await;
|
||||
reader.read_line(&mut header_line).await?
|
||||
};
|
||||
if bytes_read == 0 {
|
||||
return Ok("".to_string());
|
||||
}
|
||||
|
||||
let header_line = header_line.trim_end();
|
||||
if header_line.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some(value) = header_line.strip_prefix("Content-Length:") {
|
||||
content_length = Some(value.trim().parse()?);
|
||||
}
|
||||
}
|
||||
|
||||
let length =
|
||||
content_length.ok_or_else(|| anyhow::anyhow!("Missing Content-Length header"))?;
|
||||
|
||||
let mut buffer = vec![0u8; length];
|
||||
{
|
||||
let mut reader = self.input.lock().await;
|
||||
reader.read_exact(&mut buffer).await?;
|
||||
}
|
||||
|
||||
let result = String::from_utf8(buffer)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn write(&self, message: &str) -> anyhow::Result<()> {
|
||||
let header = format!("Content-Length: {}\r\n\r\n", message.len());
|
||||
{
|
||||
let mut writer = self.output.lock().await;
|
||||
writer.write_all(header.as_bytes()).await?;
|
||||
writer.write_all(message.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown(&self, timeout: std::time::Duration) -> anyhow::Result<()> {
|
||||
log::info!("LSP: Shutting down server.");
|
||||
|
||||
let child = {
|
||||
let mut child_guard = self.child.lock().await;
|
||||
match child_guard.take() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
log::warn!("LSP: Server already shut down.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut child = child;
|
||||
let shutdown = child.status();
|
||||
let timeout_future = Timer::after(timeout);
|
||||
futures::select! {
|
||||
_ = shutdown.fuse() => {},
|
||||
_ = timeout_future.fuse() => {
|
||||
// On *nix platforms, send a SIGTERM with a 2s grace period
|
||||
// before killing the process.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::unistd::Pid;
|
||||
use std::time::Duration;
|
||||
const SIGTERM_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
if kill(Pid::from_raw(child.id() as i32), Signal::SIGTERM).is_ok() {
|
||||
Timer::after(SIGTERM_TIMEOUT).await;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the stderr task because it owns the last logger clone.
|
||||
// Joining it ensures that clone is dropped before restart so the same
|
||||
// log path can be registered again without colliding with a stale entry.
|
||||
if let Some(stderr_task) = self.stderr_task.lock().await.take() {
|
||||
if let Err(e) = stderr_task.await {
|
||||
log::warn!("LSP: Failed to join stderr task: {e}");
|
||||
}
|
||||
}
|
||||
log::info!("LSP: Server shut down.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lsp_types::{
|
||||
FileChangeType, FileEvent, Location as LspLocation, LocationLink, Position as LspPosition,
|
||||
Range as LspRange,
|
||||
};
|
||||
|
||||
use crate::config::{lsp_uri_to_path, path_to_lsp_uri};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileLocation {
|
||||
pub path: PathBuf,
|
||||
pub location: Location,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Location {
|
||||
pub line: usize,
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
pub fn into_lsp(self) -> LspPosition {
|
||||
LspPosition {
|
||||
line: self.line as u32,
|
||||
character: self.column as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LspLocation> for Location {
|
||||
fn from(location: LspLocation) -> Self {
|
||||
Self {
|
||||
line: location.range.start.line as usize,
|
||||
column: location.range.start.character as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Range {
|
||||
pub start: Location,
|
||||
pub end: Location,
|
||||
}
|
||||
|
||||
impl Range {
|
||||
fn into_lsp(self) -> LspRange {
|
||||
LspRange {
|
||||
start: self.start.into_lsp(),
|
||||
end: self.end.into_lsp(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LspRange> for Range {
|
||||
fn from(range: LspRange) -> Self {
|
||||
Self {
|
||||
start: Location {
|
||||
line: range.start.line as usize,
|
||||
column: range.start.character as usize,
|
||||
},
|
||||
end: Location {
|
||||
line: range.end.line as usize,
|
||||
column: range.end.character as usize,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LspDefinitionLocation {
|
||||
origin: Option<LspRange>,
|
||||
target: LspLocation,
|
||||
}
|
||||
|
||||
impl From<LspLocation> for LspDefinitionLocation {
|
||||
fn from(location: LspLocation) -> Self {
|
||||
Self {
|
||||
origin: None,
|
||||
target: location,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LocationLink> for LspDefinitionLocation {
|
||||
fn from(location_link: LocationLink) -> Self {
|
||||
Self {
|
||||
origin: location_link.origin_selection_range,
|
||||
target: LspLocation {
|
||||
uri: location_link.target_uri,
|
||||
// Use target_selection_range (the exact identifier location) instead of
|
||||
// target_range (the full declaration range including comments/attributes)
|
||||
// to jump directly to the definition name rather than the start of the
|
||||
// declaration.
|
||||
range: location_link.target_selection_range,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DefinitionLocation {
|
||||
pub origin: Option<Range>,
|
||||
pub target: FileLocation,
|
||||
}
|
||||
|
||||
impl TryFrom<LspDefinitionLocation> for DefinitionLocation {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(location: LspDefinitionLocation) -> anyhow::Result<Self> {
|
||||
let path = lsp_uri_to_path(&location.target.uri)?;
|
||||
|
||||
Ok(Self {
|
||||
origin: location.origin.map(Into::into),
|
||||
target: FileLocation {
|
||||
path,
|
||||
location: location.target.into(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference location returned from the LSP textDocument/references request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReferenceLocation {
|
||||
pub file_path: PathBuf,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
impl TryFrom<LspLocation> for ReferenceLocation {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(location: LspLocation) -> anyhow::Result<Self> {
|
||||
let path = lsp_uri_to_path(&location.uri)?;
|
||||
|
||||
Ok(Self {
|
||||
file_path: path,
|
||||
range: location.range.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit returned by the LSP.
|
||||
pub struct TextEdit {
|
||||
pub range: Range,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl From<lsp_types::TextEdit> for TextEdit {
|
||||
fn from(edit: lsp_types::TextEdit) -> Self {
|
||||
Self {
|
||||
range: edit.range.into(),
|
||||
text: edit.new_text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Document version that should be tracked by the LSP.
|
||||
pub struct DocumentVersion(i32);
|
||||
|
||||
impl DocumentVersion {
|
||||
pub fn as_i32(&self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for DocumentVersion {
|
||||
fn from(version: usize) -> Self {
|
||||
Self(version as i32)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TextDocumentContentChangeEvent {
|
||||
pub range: Option<Range>,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl TextDocumentContentChangeEvent {
|
||||
pub fn into_lsp(self) -> lsp_types::TextDocumentContentChangeEvent {
|
||||
lsp_types::TextDocumentContentChangeEvent {
|
||||
range: self.range.map(|range| range.into_lsp()),
|
||||
range_length: None,
|
||||
text: self.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result from a hover request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HoverResult {
|
||||
/// The hover contents (documentation, type info, etc.)
|
||||
pub contents: HoverContents,
|
||||
/// The range of the symbol being hovered over, if provided by the server.
|
||||
pub range: Option<Range>,
|
||||
}
|
||||
|
||||
/// The kind of markup content.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MarkupKind {
|
||||
PlainText,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HoverContentSection {
|
||||
pub value: String,
|
||||
pub kind: MarkupKind,
|
||||
}
|
||||
|
||||
/// The contents of a hover response.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HoverContents {
|
||||
pub sections: Vec<HoverContentSection>,
|
||||
}
|
||||
|
||||
impl HoverContents {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sections.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lsp_types::Hover> for HoverResult {
|
||||
fn from(hover: lsp_types::Hover) -> Self {
|
||||
let contents = match hover.contents {
|
||||
lsp_types::HoverContents::Scalar(value) => vec![HoverContentSection {
|
||||
value: marked_string_to_string(value),
|
||||
kind: MarkupKind::Markdown,
|
||||
}],
|
||||
lsp_types::HoverContents::Array(values) => values
|
||||
.into_iter()
|
||||
.map(|value| HoverContentSection {
|
||||
value: marked_string_to_string(value),
|
||||
kind: MarkupKind::Markdown,
|
||||
})
|
||||
.collect(),
|
||||
lsp_types::HoverContents::Markup(content) => {
|
||||
let kind = match content.kind {
|
||||
lsp_types::MarkupKind::PlainText => MarkupKind::PlainText,
|
||||
lsp_types::MarkupKind::Markdown => MarkupKind::Markdown,
|
||||
};
|
||||
vec![HoverContentSection {
|
||||
value: content.value,
|
||||
kind,
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
contents: HoverContents { sections: contents },
|
||||
range: hover.range.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn marked_string_to_string(marked: lsp_types::MarkedString) -> String {
|
||||
match marked {
|
||||
lsp_types::MarkedString::String(s) => s,
|
||||
lsp_types::MarkedString::LanguageString(ls) => {
|
||||
format!("```{}\n{}\n```", ls.language, ls.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file change event that can be forwarded to the language server using
|
||||
/// `workspace/didChangeWatchedFiles`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WatchedFileChangeEvent {
|
||||
pub path: PathBuf,
|
||||
pub typ: FileChangeType,
|
||||
}
|
||||
|
||||
impl WatchedFileChangeEvent {
|
||||
pub fn into_lsp(self) -> anyhow::Result<FileEvent> {
|
||||
Ok(FileEvent {
|
||||
uri: path_to_lsp_uri(&self.path)?,
|
||||
typ: self.typ,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user