Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+49
View File
@@ -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"] }
+9
View File
@@ -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
+11
View File
@@ -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(())
}
+176
View File
@@ -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(())
}
+51
View File
@@ -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
}
}
+344
View File
@@ -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;
+218
View File
@@ -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"));
}
+311
View File
@@ -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>,
}
+136
View File
@@ -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());
}
+339
View File
@@ -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 {}
+748
View File
@@ -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();
}
}
+212
View File
@@ -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
}
}
+243
View File
@@ -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!()
}
}
+106
View File
@@ -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!()
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod clangd;
pub mod go;
pub mod pyright;
pub mod rust;
pub mod typescript_language_server;
+234
View File
@@ -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!()
}
}
+183
View File
@@ -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!()
}
}
+742
View File
@@ -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())
}
}
+214
View File
@@ -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()
}
}
+198
View File
@@ -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(())
}
}
+280
View File
@@ -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,
})
}
}