pub mod client;
pub mod diagnostics_pane;
pub mod outline_pane;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use crate::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pos {
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Range {
pub start: Pos,
pub end: Pos,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
Hint,
}
impl Severity {
fn from_lsp(n: u64) -> Severity {
match n {
1 => Severity::Error,
2 => Severity::Warning,
3 => Severity::Info,
_ => Severity::Hint,
}
}
}
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub range: Range,
pub severity: Severity,
pub message: String,
pub source: Option<String>,
}
pub type CompletionItemTuple = (
String,
String,
Option<String>,
Option<String>,
serde_json::Value,
bool,
u8,
);
#[derive(Debug)]
pub enum LspEvent {
Diagnostics {
path: PathBuf,
diags: Vec<Diagnostic>,
},
GotoDefinition {
path: PathBuf,
line: u32,
character: u32,
},
Hover { text: String },
References(Vec<(PathBuf, u32, u32)>),
Rename(Vec<(PathBuf, Vec<(Range, String)>)>),
ApplyEdit {
label: Option<String>,
edits: Vec<(PathBuf, Vec<(Range, String)>)>,
},
Completion(Vec<CompletionItemTuple>),
CompletionResolve {
label: String,
detail: Option<String>,
documentation: Option<String>,
},
Formatting {
path: PathBuf,
edits: Vec<(Range, String)>,
},
WillSaveWaitUntil {
path: PathBuf,
edits: Vec<(Range, String)>,
},
CodeAction(Vec<CodeAction>),
CodeActionResolve {
edit: Option<WorkspaceEdit>,
command: Option<CodeCommand>,
},
DocumentSymbols(Vec<DocumentSymbol>),
WorkspaceSymbols(Vec<WorkspaceSymbol>),
SignatureHelp(SignatureHelp),
InlayHints {
path: PathBuf,
hints: Vec<InlayHint>,
},
SemanticTokens {
path: PathBuf,
tokens: Vec<SemanticToken>,
},
CodeLens {
path: PathBuf,
lenses: Vec<CodeLens>,
},
CodeLensResolve {
path: PathBuf,
lens_index: usize,
lens: CodeLens,
},
DocumentLinks {
path: PathBuf,
links: Vec<DocumentLink>,
},
FoldingRanges {
path: PathBuf,
ranges: Vec<(u32, u32)>,
},
SelectionRanges {
path: PathBuf,
ranges: Vec<(u32, u32, u32, u32)>,
},
DocumentColor {
path: PathBuf,
colors: Vec<ColorDecoration>,
},
DocumentHighlights {
path: PathBuf,
ranges: Vec<(u32, u32, u32, u32)>,
},
CallHierarchyPrepared {
direction: CallHierarchyDirection,
items: Vec<CallHierarchyItem>,
},
CallHierarchyCalls {
direction: CallHierarchyDirection,
origin_name: String,
hits: Vec<CallHit>,
},
TypeHierarchyPrepared {
direction: TypeHierarchyDirection,
items: Vec<CallHierarchyItem>,
},
TypeHierarchyTypes {
direction: TypeHierarchyDirection,
origin_name: String,
hits: Vec<CallHit>,
},
ProgressBegin { token: String, title: String },
ProgressReport { token: String, title: String },
ProgressEnd { token: String },
Message(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallHierarchyDirection {
Incoming,
Outgoing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypeHierarchyDirection {
Supertypes,
Subtypes,
}
#[derive(Debug, Clone)]
pub struct CallHierarchyItem {
pub name: String,
pub kind: u32,
pub path: PathBuf,
pub line: u32,
pub character: u32,
pub raw: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct CallHit {
pub name: String,
pub path: PathBuf,
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone)]
pub struct SemanticToken {
pub line: u32,
pub start_char: u32,
pub length: u32,
pub type_name: String,
pub modifiers: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ColorDecoration {
pub line: u32,
pub start_char: u32,
pub end_char: u32,
pub rgb: u32,
}
#[derive(Debug, Clone)]
pub struct DocumentLink {
pub line: u32,
pub start_char: u32,
pub end_char: u32,
pub target: String,
}
#[derive(Debug, Clone)]
pub struct InlayHint {
pub line: u32,
pub character: u32,
pub label: String,
}
#[derive(Debug, Clone)]
pub struct CodeLens {
pub line: u32,
pub title: String,
pub command: Option<CodeCommand>,
pub raw: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct DocumentSymbol {
pub name: String,
pub kind: &'static str,
pub line: u32,
pub character: u32,
pub depth: u32,
}
#[derive(Debug, Clone)]
pub struct SignatureHelp {
pub signatures: Vec<SignatureInfo>,
pub active_signature: usize,
}
#[derive(Debug, Clone)]
pub struct SignatureInfo {
pub label: String,
pub parameters: Vec<(usize, usize)>,
pub active_parameter: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct WorkspaceSymbol {
pub name: String,
pub kind: &'static str,
pub path: PathBuf,
pub line: u32,
pub character: u32,
pub container: Option<String>,
}
pub type WorkspaceEdit = Vec<(PathBuf, Vec<(Range, String)>)>;
#[derive(Debug, Clone)]
pub struct CodeAction {
pub title: String,
pub kind: Option<String>,
pub edit: Option<WorkspaceEdit>,
pub command: Option<CodeCommand>,
pub raw: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct CodeCommand {
pub command: String,
pub arguments: Vec<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub name: String,
pub cmd: String,
pub args: Vec<String>,
pub extensions: Vec<String>,
pub root_markers: Vec<String>,
pub language_id: String,
}
fn derive_lsp_language_id(path: &Path, fallback: &str) -> String {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
match ext.as_str() {
"tsx" => "typescriptreact".to_string(),
"jsx" => "javascriptreact".to_string(),
"js" | "mjs" | "cjs" => "javascript".to_string(),
"ts" | "mts" | "cts" => "typescript".to_string(),
"c" | "h" => "c".to_string(),
"cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => "cpp".to_string(),
_ => fallback.to_string(),
}
}
fn builtin_servers() -> Vec<ServerConfig> {
let s = |name: &str, cmd: &str, args: &[&str], exts: &[&str], roots: &[&str], lang: &str| {
ServerConfig {
name: name.to_string(),
cmd: cmd.to_string(),
args: args.iter().map(|a| a.to_string()).collect(),
extensions: exts.iter().map(|e| e.to_string()).collect(),
root_markers: roots.iter().map(|r| r.to_string()).collect(),
language_id: lang.to_string(),
}
};
vec![
s(
"rust",
"rust-analyzer",
&[],
&["rs"],
&["Cargo.toml"],
"rust",
),
s(
"python",
"pyright-langserver",
&["--stdio"],
&["py"],
&["pyproject.toml", "setup.py", "requirements.txt"],
"python",
),
s(
"typescript",
"typescript-language-server",
&["--stdio"],
&["ts", "tsx", "js", "jsx"],
&["tsconfig.json", "jsconfig.json", "package.json"],
"typescript",
),
s("go", "gopls", &[], &["go"], &["go.mod"], "go"),
s(
"c",
"clangd",
&[],
&["c", "h", "cpp", "hpp", "cc"],
&["compile_commands.json", ".clangd"],
"cpp",
),
s(
"csharp",
"omnisharp",
&["-lsp"],
&["cs", "csx", "cake"],
&["*.sln", "*.csproj", "global.json"],
"csharp",
),
]
}
fn server_configs(cfg: &Config) -> Vec<ServerConfig> {
let mut by_name: HashMap<String, ServerConfig> = builtin_servers()
.into_iter()
.map(|s| (s.name.clone(), s))
.collect();
for (name, val) in &cfg.lsp {
let t = match val.as_table() {
Some(t) => t,
None => continue,
};
let str_of = |k: &str| t.get(k).and_then(|v| v.as_str()).map(str::to_string);
let strs_of = |k: &str| {
t.get(k).and_then(|v| v.as_array()).map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect::<Vec<_>>()
})
};
let base = by_name.get(name).cloned();
let merged = ServerConfig {
name: name.clone(),
cmd: str_of("cmd")
.or_else(|| base.as_ref().map(|b| b.cmd.clone()))
.unwrap_or_else(|| name.clone()),
args: strs_of("args")
.or_else(|| base.as_ref().map(|b| b.args.clone()))
.unwrap_or_default(),
extensions: strs_of("extensions")
.or_else(|| base.as_ref().map(|b| b.extensions.clone()))
.unwrap_or_default(),
root_markers: strs_of("root_markers")
.or_else(|| base.as_ref().map(|b| b.root_markers.clone()))
.unwrap_or_default(),
language_id: str_of("language_id")
.or_else(|| base.as_ref().map(|b| b.language_id.clone()))
.unwrap_or_else(|| name.clone()),
};
by_name.insert(name.clone(), merged);
}
by_name.into_values().collect()
}
fn find_root(file: &Path, markers: &[String], fallback: &Path) -> PathBuf {
let start = file.parent().unwrap_or(fallback);
let mut cur = Some(start);
while let Some(dir) = cur {
if markers.iter().any(|m| marker_matches(dir, m)) {
return dir.to_path_buf();
}
cur = dir.parent();
}
start.to_path_buf()
}
fn marker_matches(dir: &Path, marker: &str) -> bool {
if let Some(suffix) = marker.strip_prefix('*') {
std::fs::read_dir(dir)
.map(|entries| {
entries.flatten().any(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.ends_with(suffix))
})
})
.unwrap_or(false)
} else {
dir.join(marker).exists()
}
}
pub struct LspManager {
workspace: PathBuf,
servers: Vec<ServerConfig>,
clients: HashMap<(PathBuf, String), client::LspClient>,
dead: std::collections::HashSet<String>,
tx: mpsc::Sender<LspEvent>,
rx: mpsc::Receiver<LspEvent>,
}
impl LspManager {
pub fn new(workspace: &Path, cfg: &Config) -> LspManager {
let (tx, rx) = mpsc::channel();
LspManager {
workspace: workspace.to_path_buf(),
servers: server_configs(cfg),
clients: HashMap::new(),
dead: std::collections::HashSet::new(),
tx,
rx,
}
}
pub fn is_empty(&self) -> bool {
self.clients.is_empty()
}
pub fn server_count(&self) -> usize {
self.clients.len()
}
pub fn restart_all(&mut self) {
self.clients.clear();
self.dead.clear();
}
pub fn servers_running(&self) -> Vec<(String, PathBuf)> {
let mut v: Vec<_> = self
.clients
.keys()
.map(|(root, name)| (name.clone(), root.clone()))
.collect();
v.sort();
v
}
fn server_for_ext(&self, ext: &str) -> Option<ServerConfig> {
self.servers
.iter()
.find(|s| s.extensions.iter().any(|e| e == ext))
.cloned()
}
fn ensure_client(&mut self, path: &Path) -> Option<((PathBuf, String), String)> {
let ext = path.extension()?.to_str()?.to_string();
let sc = self.server_for_ext(&ext)?;
if self.dead.contains(&sc.name) {
return None;
}
let root = find_root(path, &sc.root_markers, &self.workspace);
let key = (root.clone(), sc.name.clone());
if !self.clients.contains_key(&key) {
match client::LspClient::spawn(&sc, &root, self.tx.clone()) {
Ok(c) => {
self.clients.insert(key.clone(), c);
}
Err(e) => {
self.dead.insert(sc.name.clone());
let short = if e.to_lowercase().contains("not found")
|| e.to_lowercase().contains("no such file")
{
match install_hint_for(&sc.cmd) {
Some(install) => {
format!("LSP: {} not installed — `{install}`", sc.cmd)
}
None => format!("LSP: {} not installed — install it on PATH", sc.cmd),
}
} else {
format!("LSP: {} unavailable", sc.cmd)
};
let _ = self.tx.send(LspEvent::Message(short));
return None;
}
}
}
let language_id = derive_lsp_language_id(path, &sc.language_id);
Some((key, language_id))
}
pub fn did_open(&mut self, path: &Path, text: &str) {
if let Some((key, lang)) = self.ensure_client(path)
&& let Some(c) = self.clients.get_mut(&key)
{
c.did_open(path, &lang, text);
}
}
pub fn did_change(&mut self, path: &Path, text: &str) {
for c in self.clients.values_mut() {
c.did_change(path, text);
}
}
pub fn did_save(&mut self, path: &Path, text: &str) {
for c in self.clients.values_mut() {
c.did_save(path, text);
}
}
pub fn did_close(&mut self, path: &Path) {
for c in self.clients.values_mut() {
c.did_close(path);
}
}
pub fn goto_definition(&mut self, path: &Path, line: u32, character: u32) -> bool {
self.request_at("textDocument/definition", path, line, character)
}
pub fn goto_declaration(&mut self, path: &Path, line: u32, character: u32) -> bool {
self.request_at("textDocument/declaration", path, line, character)
}
pub fn goto_type_definition(&mut self, path: &Path, line: u32, character: u32) -> bool {
self.request_at("textDocument/typeDefinition", path, line, character)
}
pub fn goto_implementation(&mut self, path: &Path, line: u32, character: u32) -> bool {
self.request_at("textDocument/implementation", path, line, character)
}
pub fn hover(&mut self, path: &Path, line: u32, character: u32) -> bool {
self.request_at("textDocument/hover", path, line, character)
}
pub fn references(&mut self, path: &Path, line: u32, character: u32) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.references(path, line, character);
sent = true;
}
}
sent
}
pub fn rename(&mut self, path: &Path, line: u32, character: u32, new_name: &str) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.rename(path, line, character, new_name);
sent = true;
}
}
sent
}
pub fn completion(&mut self, path: &Path, line: u32, character: u32) -> bool {
self.request_at("textDocument/completion", path, line, character)
}
pub fn completion_resolve(
&mut self,
path: &Path,
label: &str,
item: serde_json::Value,
) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.completion_resolve(item.clone(), label);
sent = true;
}
}
sent
}
pub fn code_action_resolve(&mut self, path: &Path, action: serde_json::Value) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.code_action_resolve(action.clone());
sent = true;
}
}
sent
}
pub fn code_lens_resolve(
&mut self,
path: &Path,
lens: serde_json::Value,
lens_index: usize,
) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.code_lens_resolve(lens.clone(), lens_index);
sent = true;
}
}
sent
}
pub fn formatting(&mut self, path: &Path, tab_size: u32, insert_spaces: bool) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.formatting(path, tab_size, insert_spaces);
sent = true;
}
}
sent
}
pub fn will_save_wait_until(&mut self, path: &Path) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.will_save_wait_until(path);
sent = true;
}
}
sent
}
pub fn document_symbol(&mut self, path: &Path) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.document_symbol(path);
sent = true;
}
}
sent
}
pub fn workspace_symbol(&mut self, query: &str) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
c.workspace_symbol(query);
sent = true;
}
sent
}
pub fn signature_help(&mut self, path: &Path, line: u32, character: u32) -> bool {
self.request_at("textDocument/signatureHelp", path, line, character)
}
pub fn inlay_hint(&mut self, path: &Path, line_count: u32) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.inlay_hint(path, line_count);
sent = true;
}
}
sent
}
pub fn semantic_tokens(
&mut self,
path: &Path,
line_count: u32,
viewport: Option<(u32, u32)>,
) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.semantic_tokens(path, line_count, viewport);
sent = true;
}
}
sent
}
pub fn document_link(&mut self, path: &Path) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.document_link(path);
sent = true;
}
}
sent
}
pub fn folding_range(&mut self, path: &Path) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.folding_range(path);
sent = true;
}
}
sent
}
pub fn selection_range(&mut self, path: &Path, line: u32, character: u32) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.selection_range(path, line, character);
sent = true;
}
}
sent
}
pub fn document_color(&mut self, path: &Path) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.document_color(path);
sent = true;
}
}
sent
}
pub fn document_highlight(&mut self, path: &Path, line: u32, character: u32) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.document_highlight(path, line, character);
sent = true;
}
}
sent
}
pub fn prepare_call_hierarchy(
&mut self,
path: &Path,
line: u32,
character: u32,
direction: CallHierarchyDirection,
) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.prepare_call_hierarchy(path, line, character, direction);
sent = true;
}
}
sent
}
pub fn on_type_formatting(
&mut self,
path: &Path,
line: u32,
character: u32,
trigger: char,
tab_size: u32,
insert_spaces: bool,
) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.on_type_formatting(path, line, character, trigger, tab_size, insert_spaces);
sent = true;
}
}
sent
}
pub fn call_hierarchy_incoming(&mut self, item: &CallHierarchyItem) {
for c in self.clients.values_mut() {
if c.is_open(&item.path) {
c.call_hierarchy_calls(item, CallHierarchyDirection::Incoming);
return;
}
}
}
pub fn call_hierarchy_outgoing(&mut self, item: &CallHierarchyItem) {
for c in self.clients.values_mut() {
if c.is_open(&item.path) {
c.call_hierarchy_calls(item, CallHierarchyDirection::Outgoing);
return;
}
}
}
pub fn prepare_type_hierarchy(
&mut self,
path: &Path,
line: u32,
character: u32,
direction: TypeHierarchyDirection,
) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.prepare_type_hierarchy(path, line, character, direction);
sent = true;
}
}
sent
}
pub fn type_hierarchy_supertypes(&mut self, item: &CallHierarchyItem) {
for c in self.clients.values_mut() {
if c.is_open(&item.path) {
c.type_hierarchy_types(item, TypeHierarchyDirection::Supertypes);
return;
}
}
}
pub fn type_hierarchy_subtypes(&mut self, item: &CallHierarchyItem) {
for c in self.clients.values_mut() {
if c.is_open(&item.path) {
c.type_hierarchy_types(item, TypeHierarchyDirection::Subtypes);
return;
}
}
}
pub fn code_lens(&mut self, path: &Path) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.code_lens(path);
sent = true;
}
}
sent
}
pub fn code_action(&mut self, path: &Path, range: Range, diagnostics: &[Diagnostic]) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.code_action(path, range, diagnostics);
sent = true;
}
}
sent
}
pub fn code_action_with_only(
&mut self,
path: &Path,
range: Range,
diagnostics: &[Diagnostic],
only: &[String],
) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.code_action_with_only(path, range, diagnostics, only);
sent = true;
}
}
sent
}
pub fn execute_command(&mut self, path: &Path, cmd: &CodeCommand) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.execute_command(cmd);
sent = true;
}
}
sent
}
fn request_at(&mut self, method: &str, path: &Path, line: u32, character: u32) -> bool {
let mut sent = false;
for c in self.clients.values_mut() {
if c.is_open(path) {
c.request_text_position(method, path, line, character);
sent = true;
}
}
sent
}
pub fn poll(&mut self) -> Vec<LspEvent> {
self.rx.try_iter().collect()
}
}
pub(crate) fn path_to_uri(path: &Path) -> String {
let s = path.to_string_lossy();
let mut out = String::from("file://");
for b in s.bytes() {
match b {
b'/' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pub(crate) fn uri_to_path(uri: &str) -> Option<PathBuf> {
let rest = uri.strip_prefix("file://")?;
let mut bytes = Vec::with_capacity(rest.len());
let mut it = rest.bytes();
while let Some(b) = it.next() {
if b == b'%' {
let h = it.next()?;
let l = it.next()?;
let hv = (h as char).to_digit(16)?;
let lv = (l as char).to_digit(16)?;
bytes.push((hv * 16 + lv) as u8);
} else {
bytes.push(b);
}
}
Some(PathBuf::from(String::from_utf8_lossy(&bytes).into_owned()))
}
pub(crate) fn parse_diagnostic(v: &serde_json::Value) -> Option<Diagnostic> {
let r = v.get("range")?;
let pos = |k: &str| -> Option<Pos> {
let p = r.get(k)?;
Some(Pos {
line: p.get("line")?.as_u64()? as u32,
character: p.get("character")?.as_u64()? as u32,
})
};
Some(Diagnostic {
range: Range {
start: pos("start")?,
end: pos("end")?,
},
severity: v
.get("severity")
.and_then(|s| s.as_u64())
.map(Severity::from_lsp)
.unwrap_or(Severity::Error),
message: v
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string(),
source: v.get("source").and_then(|s| s.as_str()).map(str::to_string),
})
}
pub(crate) fn byte_at(text: &str, line: u32, character: u32) -> Option<usize> {
let mut start = 0usize;
for _ in 0..line {
let nl = text[start..].find('\n')?;
start += nl + 1;
}
let line_text = match text[start..].find('\n') {
Some(nl) => &text[start..start + nl],
None => &text[start..],
};
match line_text.char_indices().nth(character as usize) {
Some((off, _)) => Some(start + off),
None => Some(start + line_text.len()),
}
}
fn install_hint_for(cmd: &str) -> Option<&'static str> {
let base = std::path::Path::new(cmd)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(cmd);
match base {
"rust-analyzer" => Some("rustup component add rust-analyzer"),
"typescript-language-server" => Some("npm i -g typescript typescript-language-server"),
"tsserver" => Some("npm i -g typescript"),
"pylsp" => Some("pip install python-lsp-server"),
"pyright" | "pyright-langserver" => Some("npm i -g pyright"),
"gopls" => Some("go install golang.org/x/tools/gopls@latest"),
"clangd" => Some("brew install llvm / apt install clangd"),
"omnisharp" | "OmniSharp" => Some(
"brew install omnisharp / see https://github.com/OmniSharp/omnisharp-roslyn/releases",
),
"csharp-ls" => Some("dotnet tool install -g csharp-ls"),
"lua-language-server" => Some("brew install lua-language-server"),
"ruby-lsp" => Some("gem install ruby-lsp"),
"solargraph" => Some("gem install solargraph"),
"bash-language-server" => Some("npm i -g bash-language-server"),
"vscode-html-language-server"
| "vscode-css-language-server"
| "vscode-json-language-server"
| "vscode-eslint-language-server" => Some("npm i -g vscode-langservers-extracted"),
"yaml-language-server" => Some("npm i -g yaml-language-server"),
"vue-language-server" => Some("npm i -g @vue/language-server"),
"tailwindcss-language-server" => Some("npm i -g @tailwindcss/language-server"),
"marksman" => Some("brew install marksman"),
"taplo" => Some("cargo install taplo-cli --features lsp"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_at_resolves_positions() {
let t = "ab\ncde\nf";
assert_eq!(byte_at(t, 0, 0), Some(0));
assert_eq!(byte_at(t, 0, 2), Some(2)); assert_eq!(byte_at(t, 1, 0), Some(3));
assert_eq!(byte_at(t, 1, 1), Some(4));
assert_eq!(byte_at(t, 1, 9), Some(6)); assert_eq!(byte_at(t, 2, 0), Some(7));
assert_eq!(byte_at(t, 3, 0), None); }
#[test]
fn uri_round_trips() {
let p = Path::new("/tmp/a b/x.rs");
let u = path_to_uri(p);
assert!(u.starts_with("file:///tmp/a%20b/x.rs"));
assert_eq!(uri_to_path(&u).unwrap(), p);
}
#[test]
fn ext_lookup_hits_builtins() {
let cfg = Config::default();
let m = LspManager::new(Path::new("/tmp"), &cfg);
assert!(m.server_for_ext("rs").is_some());
assert_eq!(m.server_for_ext("rs").unwrap().cmd, "rust-analyzer");
assert!(m.server_for_ext("zzz").is_none());
}
#[test]
fn config_overrides_builtin() {
let mut cfg = Config::default();
let mut t = toml::value::Table::new();
t.insert("cmd".into(), toml::Value::String("my-ra".into()));
cfg.lsp.insert("rust".into(), toml::Value::Table(t));
let m = LspManager::new(Path::new("/tmp"), &cfg);
assert_eq!(m.server_for_ext("rs").unwrap().cmd, "my-ra");
assert_eq!(m.server_for_ext("rs").unwrap().language_id, "rust");
}
#[test]
fn parse_diagnostic_basic() {
let v = serde_json::json!({
"range": {"start": {"line": 3, "character": 1}, "end": {"line": 3, "character": 5}},
"severity": 2, "message": "unused", "source": "rustc"
});
let d = parse_diagnostic(&v).unwrap();
assert_eq!(d.severity, Severity::Warning);
assert_eq!(d.range.start.line, 3);
assert_eq!(d.source.as_deref(), Some("rustc"));
}
#[test]
fn derive_lsp_language_id_maps_typescript_and_c_variants() {
let p = |s: &str| std::path::PathBuf::from(s);
assert_eq!(derive_lsp_language_id(&p("a.ts"), "fallback"), "typescript");
assert_eq!(
derive_lsp_language_id(&p("a.tsx"), "fallback"),
"typescriptreact"
);
assert_eq!(derive_lsp_language_id(&p("a.js"), "fallback"), "javascript");
assert_eq!(
derive_lsp_language_id(&p("a.jsx"), "fallback"),
"javascriptreact"
);
assert_eq!(
derive_lsp_language_id(&p("a.mts"), "fallback"),
"typescript"
);
assert_eq!(
derive_lsp_language_id(&p("a.cjs"), "fallback"),
"javascript"
);
assert_eq!(derive_lsp_language_id(&p("a.c"), "fallback"), "c");
assert_eq!(derive_lsp_language_id(&p("a.h"), "fallback"), "c");
assert_eq!(derive_lsp_language_id(&p("a.cpp"), "fallback"), "cpp");
assert_eq!(derive_lsp_language_id(&p("a.hxx"), "fallback"), "cpp");
assert_eq!(derive_lsp_language_id(&p("a.rs"), "rust"), "rust");
assert_eq!(derive_lsp_language_id(&p("Makefile"), "make"), "make");
}
#[test]
fn marker_matches_literal_and_glob() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("global.json"), "{}").unwrap();
std::fs::write(dir.path().join("Acme.sln"), "").unwrap();
std::fs::write(dir.path().join("Nested.csproj"), "").unwrap();
assert!(marker_matches(dir.path(), "global.json"));
assert!(!marker_matches(dir.path(), "Cargo.toml"));
assert!(marker_matches(dir.path(), "*.sln"));
assert!(marker_matches(dir.path(), "*.csproj"));
assert!(!marker_matches(dir.path(), "*.xyz"));
}
#[test]
fn find_root_climbs_to_csproj() {
let root = tempfile::tempdir().unwrap();
let src = root.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(root.path().join("Acme.csproj"), "").unwrap();
let file = src.join("Foo.cs");
std::fs::write(&file, "").unwrap();
let found = find_root(
&file,
&["*.sln".into(), "*.csproj".into(), "global.json".into()],
root.path(),
);
assert_eq!(
std::fs::canonicalize(&found).unwrap(),
std::fs::canonicalize(root.path()).unwrap(),
);
}
}