diff --git a/Cargo.lock b/Cargo.lock index fbb0a6118..cf655846a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4372,6 +4372,7 @@ dependencies = [ "tempfile", "toml 0.9.12+spec-1.1.0", "tracing", + "which", "zip 2.4.2", ] diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index ac0a3cf8f..20d71aecd 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -69,6 +69,10 @@ pub struct LanguageServerConfig { /// Files whose presence marks a workspace root, e.g. `Cargo.toml`. #[serde(default)] pub root_markers: Vec, + /// Serve only files below a root marker, not the whole project: a framework's + /// server (Angular) next to the language's own one. + #[serde(default)] + pub require_root_marker: bool, /// JSON passed as `initializationOptions`. #[serde(default)] pub initialization_options: Option, diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index 468d51c41..85aa1ffad 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -523,12 +523,35 @@ mod tests { ("typescript", vec!["vtsls", "typescript-language-server"]), ] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); - let got: Vec<(&str, bool)> = cfg.language_servers.iter().map(|s| (s.name.as_str(), s.fallback)).collect(); + let got: Vec<(&str, bool)> = cfg + .language_servers + .iter() + .filter(|s| !s.require_root_marker) + .map(|s| (s.name.as_str(), s.fallback)) + .collect(); let expected: Vec<(&str, bool)> = servers.iter().enumerate().map(|(i, name)| (*name, i > 0)).collect(); assert_eq!(got, expected); } } + #[test] + fn angular_server_joins_typescript_and_html_in_angular_projects() { + let reg = reg(); + let names = |lang: &str| -> Vec { + reg.get(&LanguageId::new(lang)).unwrap().language_servers.iter().map(|s| s.name.clone()).collect() + }; + // After the TypeScript fallback (an available server before it would hide + // it); before the HTML server (hover, rename and symbols take the first). + assert_eq!(names("typescript"), ["vtsls", "typescript-language-server", "angular-language-server"]); + assert_eq!(names("html"), ["angular-language-server", "vscode-html-language-server"]); + for lang in ["typescript", "html"] { + let cfg = reg.get(&LanguageId::new(lang)).unwrap(); + let angular = cfg.language_servers.iter().find(|s| s.name == "angular-language-server").unwrap(); + assert!(angular.require_root_marker && !angular.fallback, "{lang}"); + assert_eq!(angular.root_markers, ["angular.json", "node_modules/@angular/core"], "{lang}"); + } + } + #[test] fn every_builtin_file_is_registered_and_parses() { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../languages"); diff --git a/crates/ide-lsp/src/angular.rs b/crates/ide-lsp/src/angular.rs new file mode 100644 index 000000000..ab95c58a7 --- /dev/null +++ b/crates/ide-lsp/src/angular.rs @@ -0,0 +1,273 @@ +//! The Angular Language Server next to vtsls in TypeScript files, asked like +//! VS Code's Angular extension asks it: about positions only inside a +//! component's template and styles, since it answers nothing elsewhere and +//! answers late while it analyses the project. + +pub(crate) const SERVER: &str = "angular-language-server"; + +/// Decorator fields holding templates, styles and host bindings. +const PROPERTIES: [&str; 6] = ["template", "templateUrl", "styles", "styleUrls", "styleUrl", "host"]; +/// Decorators taking host bindings as string arguments. +const DECORATORS: [&str; 2] = ["HostBinding", "HostListener"]; + +/// Whether the Angular server is asked about the position at byte `offset` of +/// a `language` document: anywhere in a template file, in TypeScript only in +/// a string of a decorator field. +pub(crate) fn serves_position(language: &str, text: &str, offset: usize) -> bool { + language != "typescript" || in_decorator_field(text, offset) +} + +/// Whether the Angular server is asked for the inlay hints of a `language` +/// document: in TypeScript only with Angular imported. +pub(crate) fn serves_inlay_hints(language: &str, text: &str) -> bool { + language != "typescript" || text.contains("@angular/core") +} + +/// Whether byte `offset` is inside a string of one of [`PROPERTIES`] (`template: +/// '...'`, `styles: ['...']`) or of an argument of one of [`DECORATORS`]. A +/// port of the VS Code extension's token scan: false positives are fine, the +/// server has the last word. +fn in_decorator_field(text: &str, offset: usize) -> bool { + let mut scanner = Scanner { text: text.as_bytes(), pos: 0, braces: 0, substitutions: Vec::new() }; + let (mut last, mut second_last, mut last_text) = (None, None, ""); + let (mut braces, mut brackets, mut parens) = (0i32, 0i32, 0i32); + // The brace and bracket depth of the property whose value is being read. + let mut property: Option<(i32, i32)> = None; + // The paren depth of the decorator whose arguments are being read. + let mut decorator: Option = None; + while let Some((tok, start, end)) = scanner.next() { + if start >= offset { + break; + } + if last == Some(Tok::Ident) && tok == Tok::Colon && PROPERTIES.contains(&last_text) { + property = Some((braces, brackets)); + continue; + } + if property == Some((braces, brackets)) && matches!(tok, Tok::Comma | Tok::Semicolon | Tok::CloseBrace) { + property = None; + } + if second_last == Some(Tok::At) + && last == Some(Tok::Ident) + && tok == Tok::OpenParen + && DECORATORS.contains(&last_text) + { + decorator = Some(parens); + } + if tok == Tok::CloseParen && decorator == Some(parens - 1) { + decorator = None; + } + match tok { + Tok::OpenBrace => braces += 1, + Tok::CloseBrace => braces -= 1, + Tok::OpenBracket => brackets += 1, + Tok::CloseBracket => brackets -= 1, + Tok::OpenParen => parens += 1, + Tok::CloseParen => parens -= 1, + _ => {} + } + if (property.is_some() || decorator.is_some()) && tok == Tok::Str && offset <= end { + return true; + } + last_text = text.get(start..end).unwrap_or(""); + second_last = last; + last = Some(tok); + } + false +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tok { + Ident, + Colon, + Comma, + Semicolon, + At, + OpenBrace, + CloseBrace, + OpenBracket, + CloseBracket, + OpenParen, + CloseParen, + /// A string or a template literal without substitutions. + Str, + /// A part of a template literal with substitutions (`` `a${`` / `}b${` / `` }c` ``). + Template, + Other, +} + +/// TypeScript tokens, enough to tell strings, names and brackets apart. +/// Like the TypeScript scanner without its parser, `/` is never a regex. +struct Scanner<'a> { + text: &'a [u8], + pos: usize, + /// Open `{`, not counting those of template substitutions. + braces: usize, + /// The brace depth each open `${` started at, innermost last. + substitutions: Vec, +} + +impl Scanner<'_> { + /// The next token and its byte range. + fn next(&mut self) -> Option<(Tok, usize, usize)> { + self.skip_trivia(); + let start = self.pos; + let c = *self.text.get(start)?; + self.pos += 1; + let tok = match c { + b'\'' | b'"' => { + self.string(c); + Tok::Str + } + b'`' => { + if self.template() { + Tok::Template + } else { + Tok::Str + } + } + b'{' => { + self.braces += 1; + Tok::OpenBrace + } + // The end of a substitution: the template literal goes on. + b'}' if self.substitutions.last() == Some(&self.braces) => { + self.substitutions.pop(); + self.template(); + Tok::Template + } + b'}' => { + self.braces = self.braces.saturating_sub(1); + Tok::CloseBrace + } + b'[' => Tok::OpenBracket, + b']' => Tok::CloseBracket, + b'(' => Tok::OpenParen, + b')' => Tok::CloseParen, + b':' => Tok::Colon, + b',' => Tok::Comma, + b';' => Tok::Semicolon, + b'@' => Tok::At, + c if is_ident(c) => { + while self.text.get(self.pos).is_some_and(|&c| is_ident(c)) { + self.pos += 1; + } + if c.is_ascii_digit() { Tok::Other } else { Tok::Ident } + } + _ => Tok::Other, + }; + Some((tok, start, self.pos)) + } + + fn skip_trivia(&mut self) { + loop { + match (self.text.get(self.pos), self.text.get(self.pos + 1)) { + (Some(c), _) if c.is_ascii_whitespace() => self.pos += 1, + (Some(b'/'), Some(b'/')) => { + while self.text.get(self.pos).is_some_and(|&c| c != b'\n') { + self.pos += 1; + } + } + (Some(b'/'), Some(b'*')) => { + self.pos += 2; + while self.pos < self.text.len() && !self.text[self.pos..].starts_with(b"*/") { + self.pos += 1; + } + self.pos = (self.pos + 2).min(self.text.len()); + } + _ => return, + } + } + } + + /// The rest of a string opened by `quote`, which a line break ends too. + fn string(&mut self, quote: u8) { + while let Some(&c) = self.text.get(self.pos) { + self.pos += if c == b'\\' { 2 } else { 1 }; + if c == quote || c == b'\n' { + break; + } + } + self.pos = self.pos.min(self.text.len()); + } + + /// The rest of a template literal part; `true` when a substitution opens it. + fn template(&mut self) -> bool { + while let Some(&c) = self.text.get(self.pos) { + match c { + b'\\' => self.pos += 2, + b'`' => { + self.pos += 1; + return false; + } + b'$' if self.text.get(self.pos + 1) == Some(&b'{') => { + self.pos += 2; + self.substitutions.push(self.braces); + return true; + } + _ => self.pos += 1, + } + } + self.pos = self.pos.min(self.text.len()); + false + } +} + +/// Identifier bytes; any non-ASCII byte belongs to one too. +fn is_ident(c: u8) -> bool { + c.is_ascii_alphanumeric() || c == b'_' || c == b'$' || c >= 0x80 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Whether the position marked `|` in `src` is in a decorator field. + fn at_marker(src: &str) -> bool { + let offset = src.find('|').expect("marker"); + in_decorator_field(&src.replacen('|', "", 1), offset) + } + + #[test] + fn template_and_style_strings_are_decorator_fields() { + assert!(at_marker("@Component({ selector: 'a', template: `

{{ |x }}

` })")); + assert!(at_marker("@Component({\n template: '

|

',\n})")); + assert!(at_marker("@Component({ templateUrl: './a.|html' })")); + assert!(at_marker("@Component({ styles: ['p {}', 'a { |}'] })")); + assert!(at_marker("@Component({ host: { '(click)': 'on|Click()' } })")); + assert!(at_marker("class A { @HostListener('click', ['$event.|target']) on() {} }")); + // Still being typed: the string runs to the end of the line. + assert!(at_marker("@Component({ template: '

|")); + } + + #[test] + fn code_and_other_strings_are_not() { + assert!(!at_marker("@Component({ selector: 'app-|root', template: '' })")); + assert!(!at_marker("@Component({ template: '' })\nclass A { t|itle = 'x'; }")); + assert!(!at_marker("@Component({ template: '' })\nclass A { title = '|x'; }")); + assert!(!at_marker("@Component({ template: |`

` })")); + assert!(!at_marker("@Component({ template: '', imports: [A|] })")); + assert!(!at_marker("class A { @HostListener('click') on() { const s = '|'; } }")); + assert!(!at_marker("// template: '|'\nclass A {}")); + assert!(!at_marker("/* template: '|' */")); + } + + #[test] + fn substitutions_dont_end_template_literals() { + // `}` closing `${...}` is not the end of the object holding `template`. + assert!(at_marker("const a = `${ {b: 1} }`;\n@Component({ template: '

|

' })")); + assert!(!at_marker("@Component({ template: `

${x}|

` })")); + assert!(at_marker("const s = `${'}'}`; @Component({ template: '|' })")); + assert!(!at_marker("const s = `${`${'a'}`}`; class A { b = '|'; }")); + } + + #[test] + fn typescript_positions_go_to_angular_only_in_decorator_fields() { + let src = "@Component({ template: '

' })\nclass A {}"; + assert!(serves_position("html", src, src.find("class").unwrap())); + assert!(!serves_position("typescript", src, src.find("class").unwrap())); + assert!(serves_position("typescript", src, src.find("

").unwrap())); + assert!(serves_inlay_hints("typescript", "import { Component } from '@angular/core';")); + assert!(!serves_inlay_hints("typescript", "export const a = 1;")); + assert!(serves_inlay_hints("html", "")); + } +} diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index eadfb18d5..74bbb6096 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -664,6 +664,36 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit { WorkspaceEdit { files } } +/// Adds the changes of `extra`, another server's edit for the same request, to +/// `edit` where they leave its own alone: what only the other server sees +/// (Angular templates next to vtsls's TypeScript). +pub fn merge_workspace_edit(edit: &mut WorkspaceEdit, extra: WorkspaceEdit) { + let overlap = + |a: &TextEdit, b: &TextEdit| a.range == b.range || (a.range.start < b.range.end && b.range.start < a.range.end); + for file in extra.files { + let FileEdit::Edit { path, edits } = file else { + if !edit.files.contains(&file) { + edit.files.push(file); + } + continue; + }; + let own = edit.files.iter_mut().find_map(|f| match f { + FileEdit::Edit { path: p, edits } if *p == path => Some(edits), + _ => None, + }); + match own { + Some(own) => { + let added: Vec = edits.into_iter().filter(|e| !own.iter().any(|o| overlap(o, e))).collect(); + if !added.is_empty() { + own.extend(added); + own.sort_by_key(|e| e.range.start); + } + } + None => edit.files.push(FileEdit::Edit { path, edits }), + } + } +} + // ---------------------------------------------------------------- navigation pub fn goto_response(conv: &Conv, r: lsp::GotoDefinitionResponse) -> Vec { @@ -990,6 +1020,33 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn merged_rename_edits_keep_the_first_servers_ones() { + let edit = |l: u32, c: u32, text: &str| TextEdit { + range: Range::new(Pos::new(l, c), Pos::new(l, c + 5)), + new_text: text.into(), + }; + let file = |p: &str, edits: Vec| FileEdit::Edit { path: PathBuf::from(p).into(), edits }; + // vtsls renames a shorthand property with an alias; the Angular server + // renames it plainly and adds the template. + let mut merged = + WorkspaceEdit { files: vec![file("/a.ts", vec![edit(3, 2, "items: entries"), edit(9, 4, "entries")])] }; + let angular = WorkspaceEdit { + files: vec![ + file("/a.ts", vec![edit(9, 4, "entries"), edit(3, 2, "entries"), edit(1, 14, "entries")]), + file("/a.html", vec![edit(0, 7, "entries")]), + ], + }; + merge_workspace_edit(&mut merged, angular); + assert_eq!( + merged.files, + [ + file("/a.ts", vec![edit(1, 14, "entries"), edit(3, 2, "items: entries"), edit(9, 4, "entries")]), + file("/a.html", vec![edit(0, 7, "entries")]), + ] + ); + } + #[test] fn snippet_text_takes_placeholder_defaults() { assert_eq!(snippet_text("foo(${1:a}, $2)$0"), "foo(a, )"); diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 0a235a152..091d84db9 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -12,6 +12,9 @@ use lsp_types as lsp; use serde_json::{Value, json}; use std::sync::{Arc, Weak}; +/// Progress token standing for `angular/projectLoadingStart` … `Finish`. +const ANGULAR_LOADING: &str = "angular/projectLoading"; + pub struct ClientHandler { mgr: Weak, server: Arc, @@ -201,6 +204,17 @@ impl Handler for ClientHandler { } } "tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params), + // The Angular server's project loading, shown like `$/progress`; one + // task while several projects load. + "angular/projectLoadingStart" if !self.server.progress.lock().contains_key(ANGULAR_LOADING) => self + .progress( + &mgr, + json!({ "token": ANGULAR_LOADING, "value": { "kind": "begin", "title": "Loading Angular project" } }), + ), + "angular/projectLoadingStart" => {} + "angular/projectLoadingFinish" => { + self.progress(&mgr, json!({ "token": ANGULAR_LOADING, "value": { "kind": "end" } })) + } "telemetry/event" | "$/logTrace" | "$/cancelRequest" => {} other => tracing::trace!("[{}] unhandled notification {other}", self.server.name), } diff --git a/crates/ide-lsp/src/inspect.rs b/crates/ide-lsp/src/inspect.rs index 95f4fb626..9fcf73e9b 100644 --- a/crates/ide-lsp/src/inspect.rs +++ b/crates/ide-lsp/src/inspect.rs @@ -66,6 +66,11 @@ impl LspManager { pub fn serves_path(&self, path: &Path) -> bool { let lang = self.languages.detect(path, None); self.language_servers(&lang).iter().any(|ls| { + if ls.require_root_marker + && crate::launch::marked_root(&self.project_root, path, &ls.root_markers).is_none() + { + return false; + } let mut cfg = ls.clone(); if let Some(hook) = self.config_hook.read().clone() { hook(&mut cfg, &self.project_root); diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 12fa92fed..bfe50de64 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -228,6 +228,11 @@ pub fn find_root(project_root: &Path, file: &Path, markers: &[String]) -> Option if !file.starts_with(project_root) { return None; } + Some(marked_root(project_root, file, markers).unwrap_or_else(|| project_root.to_path_buf())) +} + +/// The outermost ancestor of `file` within `project_root` containing a root marker. +pub fn marked_root(project_root: &Path, file: &Path, markers: &[String]) -> Option { let mut best = None; for dir in file.ancestors().skip(1) { if !dir.starts_with(project_root) { @@ -237,7 +242,7 @@ pub fn find_root(project_root: &Path, file: &Path, markers: &[String]) -> Option best = Some(dir.to_path_buf()); } } - Some(best.unwrap_or_else(|| project_root.to_path_buf())) + best } fn has_marker(dir: &Path, marker: &str) -> bool { @@ -275,6 +280,19 @@ mod tests { std::fs::remove_dir_all(&tmp).unwrap(); } + #[test] + fn marked_root_needs_a_marker() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("apps/web/src/app.ts"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let markers = vec!["angular.json".to_string(), "node_modules/@angular/core".to_string()]; + assert_eq!(marked_root(root, &file, &markers), None); + std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap(); + assert_eq!(marked_root(root, &file, &markers), Some(root.join("apps/web"))); + assert_eq!(marked_root(root, &root.join("apps/api/main.ts"), &markers), None); + } + #[test] fn search_path_contains_env_path() { let p = search_path(); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index bde52c937..4bc5d2246 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -6,6 +6,7 @@ //! IDE coordinates (UTF-16, 0-based) and returns IDE types from [`types`], //! never `lsp-types`, so the IPC surface stays stable. +mod angular; mod caps; mod convert; mod file_ops; @@ -125,6 +126,7 @@ impl Target { struct DocSnap { path: PathBuf, uri: lsp::Uri, + language: LanguageId, text: Arc, servers: Vec>, } @@ -142,6 +144,12 @@ impl DocSnap { fn pos_params(&self, t: &Target, pos: Pos) -> Value { json!({ "textDocument": self.ident(), "position": self.position(t, pos) }) } + /// Whether `t` is asked about `pos`: the Angular server not about the + /// TypeScript outside a component's template and styles (see [`angular`]). + fn asks(&self, t: &Target, pos: Pos) -> bool { + t.server.name != angular::SERVER + || angular::serves_position(self.language.as_str(), self.text.as_str(), self.text.byte_offset(pos)) + } } pub struct LspManager { @@ -306,7 +314,8 @@ impl LspManager { } /// The servers serving `path`: every regular one, and each `fallback` one - /// only while all servers listed before it are missing. + /// only while all servers listed before it are missing. One that requires a + /// root marker serves only files below one, and stands in for no other. fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec> { let mut out = Vec::new(); let mut available_before = false; @@ -314,8 +323,11 @@ impl LspManager { if sc.fallback && available_before { continue; } + if sc.require_root_marker && launch::marked_root(&self.project_root, path, &sc.root_markers).is_none() { + continue; + } let server = self.server_for(sc, language, path); - available_before |= server.status() != ide_core::event::ServerStatus::Missing; + available_before |= !sc.require_root_marker && server.status() != ide_core::event::ServerStatus::Missing; out.push(server); } out @@ -343,22 +355,24 @@ impl LspManager { let mut docs = self.docs.lock(); for (id, servers) in wanted { let Some(d) = docs.get_mut(&id) else { continue }; - for server in servers { + for server in &servers { if d.servers.iter().any(|a| a.server.id == server.id) { continue; } let mut attached = Attached { server: server.clone(), opened_gen: None }; if let Some(run) = server.running() { if run.caps.read().open_close { - user_did_open(&server, &run, &d.path, &d.uri, d.did_open_params()); + user_did_open(server, &run, &d.path, &d.uri, d.did_open_params()); } attached.opened_gen = Some(run.generation); } d.servers.push(attached); if !start.iter().any(|s: &Arc| s.id == server.id) { - start.push(server); + start.push(server.clone()); } } + // In definition order: the first server answers hover, rename and symbols. + d.servers.sort_by_key(|a| servers.iter().position(|s| s.id == a.server.id).unwrap_or(usize::MAX)); } } for server in &start { @@ -781,6 +795,7 @@ impl LspManager { Ok(DocSnap { path: d.path.clone(), uri: d.uri.clone(), + language: d.language.clone(), text: d.text.clone(), servers: d.servers.iter().map(|a| a.server.clone()).collect(), }) @@ -837,7 +852,7 @@ impl LspManager { cancel: CancellationToken, ) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "completion", |c| c.has("completionProvider")).await?; - let requests = targets.iter().map(|t| { + let requests = targets.iter().filter(|t| snap.asks(t, pos)).map(|t| { let context = match &trigger { Some(ch) if t.caps().completion_triggers().contains(ch) => { json!({ "triggerKind": 2, "triggerCharacter": ch }) @@ -924,7 +939,7 @@ impl LspManager { pub async fn hover(&self, doc: DocId, pos: Pos, cancel: CancellationToken) -> IdeResult> { let (snap, targets) = self.doc_targets(doc, "hover", |c| c.has("hoverProvider")).await?; let mut out = Outcome::new(); - for t in &targets { + for t in targets.iter().filter(|t| snap.asks(t, pos)) { let r: IdeResult> = self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT).await; match r { @@ -945,7 +960,7 @@ impl LspManager { pub async fn signature_help(&self, doc: DocId, pos: Pos) -> IdeResult> { let (snap, targets) = self.doc_targets(doc, "signature help", |c| c.has("signatureHelpProvider")).await?; let mut out = Outcome::new(); - for t in &targets { + for t in targets.iter().filter(|t| snap.asks(t, pos)) { let mut params = snap.pos_params(t, pos); params["context"] = json!({ "triggerKind": 1, "isRetrigger": false }); match self @@ -1007,7 +1022,7 @@ impl LspManager { cancel: Option<&CancellationToken>, ) -> IdeResult> { let mut out = Outcome::new(); - for t in targets { + for t in targets.iter().filter(|t| snap.asks(t, pos)) { match self .call::<_, Option>( t, @@ -1049,6 +1064,9 @@ impl LspManager { let requests = targets.iter().map(|t| { let (snap, cancel) = (&snap, &cancel); async move { + // The Angular server searches the file's own project only: more open + // files don't widen it. + let angular = t.server.name == angular::SERVER; let query = || async move { let mut params = snap.pos_params(t, pos); params["context"] = json!({ "includeDeclaration": include_declaration }); @@ -1059,7 +1077,7 @@ impl LspManager { }; let files = |v: &Vec| v.iter().filter(|l| l.uri.is_none()).map(|l| l.path.to_path_buf()).collect(); - let result = if project_wide { + let result = if project_wide && !angular { self.widened(snap, t, pos, cancel, query, files).await } else { query().await.map(|first| { @@ -1070,12 +1088,25 @@ impl LspManager { (t, result) } }); + // The Angular server lists the declaration even when not asked to: the one + // the definition at `pos` points to is taken out of its answer. + let declarations = async { + if include_declaration || !targets.iter().any(|t| t.server.name == angular::SERVER) { + return Vec::new(); + } + let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await; + self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default() + }; + let (results, declarations) = futures::join!(join_all(requests), declarations); let mut out = Outcome::new(); - for (t, r) in join_all(requests).await { + for (t, r) in results { match r { Ok(w) => { let acc = out.value.get_or_insert_with(References::default); - let locs: Vec = w.first.into_iter().chain(w.second.into_iter().flatten()).collect(); + let mut locs: Vec = w.first.into_iter().chain(w.second.into_iter().flatten()).collect(); + if t.server.name == angular::SERVER { + locs.retain(|l| !declarations.contains(l)); + } self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref())); acc.locations.extend(locs); acc.incomplete = acc.incomplete.or(w.incomplete); @@ -1296,7 +1327,19 @@ impl LspManager { pub async fn prepare_rename(&self, doc: DocId, pos: Pos) -> IdeResult> { let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?; - let t = &targets[0]; + let mut out = Outcome::new(); + for t in &targets { + match self.prepare_rename_on(&snap, t, pos).await { + Ok(Some(range)) => return Ok(Some(range)), + Ok(None) => out.value = Some(None), + Err(e) => out.fail(e), + } + } + out.finish(None) + } + + /// What `t` renames at `pos`; one without `prepareRename` renames the word there. + async fn prepare_rename_on(&self, snap: &DocSnap, t: &Target, pos: Pos) -> IdeResult> { if !t.caps().has_sub("renameProvider", "prepareProvider") { return Ok(snap.text.word_range_at(pos)); } @@ -1319,15 +1362,50 @@ impl LspManager { /// Renames across the project: like [`Self::references`], files outside the /// server's analysis scope that mention the name are opened for the request. + /// Every server that can rename there does: the Angular server adds the + /// templates to vtsls's edit, whose own edits win where both change the same text. pub async fn rename(&self, doc: DocId, pos: Pos, new_name: String) -> IdeResult { let (snap, targets) = self.doc_targets(doc, "rename", |c| c.has("renameProvider")).await?; - let t = &targets[0]; + let renaming = self.rename_targets(&snap, &targets, pos).await; + let edits = join_all(renaming.into_iter().map(|t| self.rename_on(&snap, t, pos, &new_name))).await; + let mut out = Outcome::new(); + for edit in edits { + match edit { + Ok(edit) => match &mut out.value { + Some(merged) => convert::merge_workspace_edit(merged, edit), + None => out.value = Some(edit), + }, + Err(e) => out.fail(e), + } + } + out.finish(WorkspaceEdit::default()) + } + + /// The servers that can rename at `pos`, the first one when none can (a + /// single one is asked without `prepareRename`). + async fn rename_targets<'t>(&self, snap: &DocSnap, targets: &'t [Target], pos: Pos) -> Vec<&'t Target> { + if targets.len() > 1 { + let prepared = join_all(targets.iter().map(|t| self.prepare_rename_on(snap, t, pos))).await; + let able: Vec<&Target> = + targets.iter().zip(prepared).filter(|(_, r)| matches!(r, Ok(Some(_)))).map(|(t, _)| t).collect(); + if !able.is_empty() { + return able; + } + } + vec![&targets[0]] + } + + async fn rename_on(&self, snap: &DocSnap, t: &Target, pos: Pos, new_name: &str) -> IdeResult { let query = || async { let mut params = snap.pos_params(t, pos); params["newName"] = json!(new_name); let r: Option = self.call(t, "textDocument/rename", params, None, LONG_TIMEOUT).await?; Ok(r.map(|e| convert::workspace_edit(&self.conv(t.enc()), e)).unwrap_or_default()) }; + // The Angular server renames in the file's own project only, whatever is open. + if t.server.name == angular::SERVER { + return query().await; + } let files = |e: &WorkspaceEdit| { e.files .iter() @@ -1337,7 +1415,7 @@ impl LspManager { }) .collect() }; - let w = self.widened(&snap, t, pos, &CancellationToken::new(), query, files).await?; + let w = self.widened(snap, t, pos, &CancellationToken::new(), query, files).await?; // The second edit covers the first: its files stay analysed. Ok(w.second.unwrap_or(w.first)) } @@ -1488,7 +1566,8 @@ impl LspManager { pub async fn inlay_hints(&self, doc: DocId, range: Range) -> IdeResult> { let (snap, targets) = self.doc_targets(doc, "inlay hints", |c| c.has("inlayHintProvider")).await?; - let requests = targets.iter().map(|t| { + let angular = angular::serves_inlay_hints(snap.language.as_str(), snap.text.as_str()); + let requests = targets.iter().filter(|t| angular || t.server.name != angular::SERVER).map(|t| { let params = json!({ "textDocument": snap.ident(), "range": snap.range(t, range) }); async move { ( diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index d782be7cf..007870d74 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -202,6 +202,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { args: vec![], env: Default::default(), root_markers: vec!["mock.toml".into()], + require_root_marker: false, initialization_options: Some(toml::Value::Table(toml::toml! { flag = true nested = { deep = 1 } })), settings: None, install_hint: Some(format!("install {name}")), @@ -222,6 +223,16 @@ fn harness_with_fallbacks(servers: &[(&str, Value)], fallbacks: &[&str]) -> Harn } fn harness_for(lang_id: &str, ext: &str, servers: &[(&str, Value)], fallbacks: &[&str]) -> Harness { + harness_configured(lang_id, ext, servers, |cfg| cfg.fallback = fallbacks.contains(&cfg.name.as_str())) +} + +/// `adjust` changes each server's configuration. +fn harness_configured( + lang_id: &str, + ext: &str, + servers: &[(&str, Value)], + adjust: impl Fn(&mut LanguageServerConfig), +) -> Harness { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_path_buf(); let lang = LanguageConfig { @@ -237,7 +248,11 @@ fn harness_for(lang_id: &str, ext: &str, servers: &[(&str, Value)], fallbacks: & tree_sitter: None, language_servers: servers .iter() - .map(|(n, _)| LanguageServerConfig { fallback: fallbacks.contains(n), ..server_cfg(n) }) + .map(|(n, _)| { + let mut cfg = server_cfg(n); + adjust(&mut cfg); + cfg + }) .collect(), debugger: None, }; @@ -713,6 +728,28 @@ async fn progress_tasks_and_busy_status() { assert!(tasks[2].3); } +#[tokio::test(flavor = "multi_thread")] +async fn angular_project_loading_shows_as_progress() { + let h = harness(&[("mock", incremental_caps())]); + h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + let peer = h.launcher.state("mock").peer(); + peer.notify("angular/projectLoadingStart", Value::Null).unwrap(); + h.wait_status("mock", ServerStatus::Busy).await; + peer.notify("angular/projectLoadingFinish", Value::Null).unwrap(); + h.wait_status("mock", ServerStatus::Ready).await; + let tasks: Vec<(String, bool)> = h + .events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)), + _ => None, + }) + .collect(); + assert_eq!(tasks, [("Loading Angular project".into(), false), ("Loading Angular project".into(), true)]); +} + #[tokio::test(flavor = "multi_thread")] async fn crash_restarts_and_reopens_documents() { let h = harness(&[("mock", incremental_caps())]); @@ -896,6 +933,45 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok()); } +#[tokio::test(flavor = "multi_thread")] +async fn a_server_requiring_a_root_marker_serves_only_files_below_one() { + // `framework` next to `main` (one instance for the whole project), like the + // Angular server next to vtsls. + let servers = [("main", incremental_caps()), ("framework", incremental_caps())]; + let h = harness_configured("mock", "mk", &servers, |cfg| match cfg.name.as_str() { + "framework" => cfg.require_root_marker = true, + _ => cfg.root_markers.clear(), + }); + for dir in ["plain", "app"] { + std::fs::create_dir_all(h.root.join(dir)).unwrap(); + } + std::fs::write(h.root.join("app/mock.toml"), "").unwrap(); + let (_, plain) = h.open("plain/a.mk", "x"); + h.wait_status("main", ServerStatus::Ready).await; + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(h.statuses("framework").is_empty()); + + let (_, app) = h.open("app/b.mk", "y"); + h.wait_status("framework", ServerStatus::Ready).await; + let framework = h.launcher.state("framework"); + eventually(|| framework.docs.lock().contains_key(&uri_of(&app))).await; + assert!(!framework.docs.lock().contains_key(&uri_of(&plain))); + eventually(|| h.launcher.state("main").docs.lock().len() == 2).await; + + // It stands in for no other server: with `ghost` missing, its fallback serves too. + let servers = [("ghost", json!({})), ("framework", incremental_caps()), ("alt", incremental_caps())]; + let h = harness_configured("mock", "mk", &servers, |cfg| match cfg.name.as_str() { + "framework" => cfg.require_root_marker = true, + "alt" => cfg.fallback = true, + _ => {} + }); + h.launcher.state("ghost").missing.store(true, Ordering::SeqCst); + std::fs::write(h.root.join("mock.toml"), "").unwrap(); + h.open("a.mk", "x"); + h.wait_status("framework", ServerStatus::Ready).await; + h.wait_status("alt", ServerStatus::Ready).await; +} + #[tokio::test(flavor = "multi_thread")] async fn a_fallback_leaves_when_the_preferred_server_is_enabled_again() { let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]); @@ -1044,6 +1120,128 @@ async fn navigation_rename_format_and_decorations() { assert_eq!(h.mgr.document_symbols(doc).await.unwrap(), None); } +/// The edits of `edit` in `path`, by range. +fn edits_in(edit: &WorkspaceEdit, path: &Path) -> Vec<(Range, String)> { + edit.files + .iter() + .filter_map(|f| match f { + FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => { + Some(edits.iter().map(|e| (e.range, e.new_text.clone()))) + } + _ => None, + }) + .flatten() + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn rename_merges_the_servers_that_can_rename_there() { + // `ts` can't rename inside a template string (its `prepareRename` is null), `ng` can. + let h = harness(&[("ts", incremental_caps()), ("ng", incremental_caps())]); + let (doc, path) = h.open("a.mk", "template: `{{ title }}`\ntitle = 1\n"); + h.wait_status("ts", ServerStatus::Ready).await; + h.wait_status("ng", ServerStatus::Ready).await; + let html = h.root.join("a.html"); + let (uri, html_uri) = (uri_of(&path), uri_of(&html)); + let r = |l: u32, c0: u32, c1: u32| json!({"start":{"line":l,"character":c0},"end":{"line":l,"character":c1}}); + let ng = h.launcher.state("ng"); + ng.respond("textDocument/prepareRename", json!({ "range": r(0, 14, 19), "placeholder": "title" })); + ng.respond( + "textDocument/rename", + json!({ "changes": { + uri.clone(): [{ "range": r(0, 14, 19), "newText": "heading" }, { "range": r(1, 0, 5), "newText": "heading" }], + html_uri: [{ "range": r(0, 3, 8), "newText": "heading" }], + } }), + ); + + assert_eq!(h.mgr.prepare_rename(doc, Pos::new(0, 15)).await.unwrap(), Some(rng(0, 14, 0, 19))); + let edit = h.mgr.rename(doc, Pos::new(0, 15), "heading".into()).await.unwrap(); + assert_eq!(edits_in(&edit, &path).len(), 2); + assert!(h.launcher.state("ts").requested("textDocument/rename").is_empty()); + + // Both can rename the declaration: `ng` adds what `ts` doesn't see, and + // `ts`'s own edits win where both change the same text. + let ts = h.launcher.state("ts"); + ts.respond("textDocument/prepareRename", json!({ "range": r(1, 0, 5), "placeholder": "title" })); + ts.respond( + "textDocument/rename", + json!({ "changes": { uri: [{ "range": r(1, 0, 5), "newText": "heading: x" }] } }), + ); + let edit = h.mgr.rename(doc, Pos::new(1, 2), "heading".into()).await.unwrap(); + assert_eq!(edits_in(&edit, &path), [(rng(0, 14, 0, 19), "heading".into()), (rng(1, 0, 1, 5), "heading: x".into())]); + assert_eq!(edits_in(&edit, &html), [(rng(0, 3, 0, 8), "heading".into())]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn angular_server_is_asked_about_typescript_only_in_component_templates() { + let servers = [("vtsls", incremental_caps()), ("angular-language-server", incremental_caps())]; + let h = harness_for("typescript", "ts", &servers, &[]); + let (doc, _) = h.open("a.ts", "@Component({ template: '

{{ title }}

' })\nclass A { title = 1; }\n"); + h.wait_status("vtsls", ServerStatus::Ready).await; + h.wait_status("angular-language-server", ServerStatus::Ready).await; + let ng = h.launcher.state("angular-language-server"); + let asked = |method: &str| ng.requested(method).len(); + let mgr = &h.mgr; + let ask = |pos: Pos| async move { + mgr.completion(doc, pos, None, Default::default()).await.unwrap(); + mgr.hover(doc, pos, Default::default()).await.unwrap(); + mgr.goto(doc, pos, GotoKind::Definition).await.unwrap(); + }; + + // In the class: vtsls alone, though it has no answer. + ask(Pos::new(1, 12)).await; + assert_eq!( + (asked("textDocument/completion"), asked("textDocument/hover"), asked("textDocument/definition")), + (0, 0, 0) + ); + // In the template: the Angular server too. + ask(Pos::new(0, 31)).await; + assert_eq!( + (asked("textDocument/completion"), asked("textDocument/hover"), asked("textDocument/definition")), + (1, 1, 1) + ); + + // Inlay hints only where Angular is imported. + let lines = rng(0, 0, 2, 0); + h.mgr.inlay_hints(doc, lines).await.unwrap(); + assert_eq!(asked("textDocument/inlayHint"), 0); + let (doc, _) = h.open("b.ts", "import { Component } from '@angular/core';\n"); + h.mgr.inlay_hints(doc, lines).await.unwrap(); + assert_eq!(asked("textDocument/inlayHint"), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn angular_references_leave_out_the_declaration_unless_asked() { + let servers = [("vtsls", incremental_caps()), ("angular-language-server", incremental_caps())]; + let h = harness_for("typescript", "ts", &servers, &[]); + let (doc, path) = h.open("a.ts", "class A { title = 1; }\n"); + h.wait_status("vtsls", ServerStatus::Ready).await; + h.wait_status("angular-language-server", ServerStatus::Ready).await; + let html = h.root.join("a.html"); + let declaration = + json!({ "uri": uri_of(&path), "range": {"start":{"line":0,"character":10},"end":{"line":0,"character":15}} }); + let usage = + json!({ "uri": uri_of(&html), "range": {"start":{"line":0,"character":3},"end":{"line":0,"character":8}} }); + // Like ngserver, it lists the declaration whatever `includeDeclaration` says; + // vtsls knows where it is. + let ng = h.launcher.state("angular-language-server"); + ng.respond("textDocument/references", json!([declaration, usage])); + let vtsls = h.launcher.state("vtsls"); + vtsls.respond("textDocument/definition", json!([declaration])); + + let mgr = &h.mgr; + let found = |include_declaration: bool| async move { + let refs = mgr.references(doc, Pos::new(0, 11), include_declaration, false, Default::default()).await; + let mut v: Vec<(PathBuf, Range)> = + refs.unwrap().locations.iter().map(|l| (l.path.to_path_buf(), l.range)).collect(); + v.sort(); + v + }; + assert_eq!(found(false).await, [(html.clone(), rng(0, 3, 0, 8))]); + assert_eq!(found(true).await, [(html, rng(0, 3, 0, 8)), (path, rng(0, 10, 0, 15))]); + assert_eq!(vtsls.requested("textDocument/definition").len(), 1); +} + #[tokio::test(flavor = "multi_thread")] async fn utf8_server_positions_are_converted() { let h = harness(&[( diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 8386146e0..f258c40e9 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -69,6 +69,7 @@ async fn missing_binary_is_reported_with_install_hint() { args: vec![], env: Default::default(), root_markers: vec![], + require_root_marker: false, initialization_options: None, settings: None, install_hint: Some("npm i -g nope-ls".into()), @@ -237,6 +238,7 @@ async fn typescript_language_server_end_to_end() { args: vec!["--stdio".into()], env: Default::default(), root_markers: vec!["tsconfig.json".into()], + require_root_marker: false, initialization_options: None, settings: None, install_hint: None, @@ -281,6 +283,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option Vec { + let mut lines: Vec = edit + .files + .iter() + .filter_map(|f| match f { + ide_core::FileEdit::Edit { path: p, edits } if p.to_path_buf() == path => { + Some(edits.iter().map(|e| e.range.start.line)) + } + _ => None, + }) + .flatten() + .collect(); + lines.sort(); + lines +} + +/// The Angular Language Server next to vtsls, in an external and an inline +/// template. Set `OPENIDEA_TEST_NPM_TOOLS` to a dir with `npm i +/// @angular/language-server @vtsls/language-server @angular/core @angular/common +/// @angular/compiler rxjs`; the project is created inside it, so that it +/// resolves `@angular/core`. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires @angular/language-server, @vtsls/language-server and @angular/core (OPENIDEA_TEST_NPM_TOOLS)"] +async fn angular_language_server_end_to_end() { + let tools = std::path::PathBuf::from(std::env::var("OPENIDEA_TEST_NPM_TOOLS").expect("OPENIDEA_TEST_NPM_TOOLS")); + let dir = tempfile::tempdir_in(&tools).unwrap(); + let root = dir.path().canonicalize().unwrap(); + std::fs::write(root.join("angular.json"), "{\"version\":1,\"projects\":{}}").unwrap(); + std::fs::write( + root.join("tsconfig.json"), + "{\"compilerOptions\":{\"strict\":true},\"angularCompilerOptions\":{\"strictTemplates\":true},\"include\":[\"src/**/*.ts\"]}", + ) + .unwrap(); + std::fs::create_dir_all(root.join("src/app")).unwrap(); + let app_ts = root.join("src/app/app.ts"); + let app_html = root.join("src/app/app.html"); + let inline_ts = root.join("src/app/inline.ts"); + for (path, text) in [(&app_ts, NG_APP_TS), (&app_html, NG_APP_HTML), (&inline_ts, NG_INLINE_TS)] { + std::fs::write(path, text).unwrap(); + } + + // The built-in definitions, with vtsls and the Angular server, and the probe + // locations the settings hook adds. + let builtin = LanguageRegistry::load(None).unwrap(); + let probes = tools.join("node_modules/@angular/language-server").to_string_lossy().into_owned(); + let languages = ["typescript", "html"].map(|id| { + let mut cfg = builtin.get(&LanguageId::new(id)).unwrap(); + cfg.language_servers.retain(|s| s.name == "angular-language-server" || s.name == "vtsls"); + for server in &mut cfg.language_servers { + let bin = if server.name == "vtsls" { "vtsls" } else { "ngserver" }; + server.command = tools.join("node_modules/.bin").join(bin).to_string_lossy().into_owned(); + } + let server = cfg.language_servers.iter_mut().find(|s| s.name == "angular-language-server").unwrap(); + server.args.extend(["--ngProbeLocations".into(), probes.clone(), "--tsProbeLocations".into(), probes.clone()]); + cfg + }); + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(languages.to_vec())); + let (html, ts, inline) = (DocId::next(), DocId::next(), DocId::next()); + mgr.did_open(ts, &app_ts, &LanguageId::new("typescript"), 1, NG_APP_TS); + mgr.did_open(html, &app_html, &LanguageId::new("html"), 1, NG_APP_HTML); + mgr.did_open(inline, &inline_ts, &LanguageId::new("typescript"), 1, NG_INLINE_TS); + + // `title` in `{{ title }}`. + let hover = retry("hover", Duration::from_secs(90), || { + let mgr = mgr.clone(); + async move { mgr.hover(html, Pos::new(0, 8), Default::default()).await.ok().flatten() } + }) + .await; + assert!(hover.contents.value.contains("title"), "{}", hover.contents.value); + let defs = mgr.goto(html, Pos::new(0, 8), GotoKind::Definition).await.unwrap(); + assert_eq!(defs[0].path, app_ts); + assert_eq!(defs[0].range.start.line, 7); + + // `missing` is no member of `App`. + retry("template diagnostics", Duration::from_secs(60), || { + let found = events + .lock() + .iter() + .any(|e| matches!(e, IdeEvent::Diagnostics { path, items, .. } if path == &app_html && !items.is_empty())); + async move { found.then_some(()) } + }) + .await; + + // `count` in the inline template, which vtsls can't rename: renamed there + // and in the class. + let count = Pos::new(4, 20); + let range = mgr.prepare_rename(inline, count).await.unwrap(); + assert_eq!(range, Some(Range::new(Pos::new(4, 19), Pos::new(4, 24)))); + let edit = mgr.rename(inline, count, "total".into()).await.unwrap(); + assert_eq!(edited_lines(&edit, &inline_ts), [4, 7], "{edit:?}"); + + // `title` in the class, which both servers rename: vtsls's edit and the + // template's from the Angular server. + let title = Pos::new(7, 3); + let edit = mgr.rename(ts, title, "heading".into()).await.unwrap(); + assert_eq!((edited_lines(&edit, &app_ts), edited_lines(&edit, &app_html)), (vec![7], vec![0]), "{edit:?}"); + // Its usage in the template, the declaration left out, from either file. + for (doc, pos) in [(ts, title), (html, Pos::new(0, 8))] { + let refs = mgr.references(doc, pos, false, false, Default::default()).await.unwrap(); + let found: Vec<(std::path::PathBuf, u32)> = + refs.locations.iter().map(|l| (l.path.to_path_buf(), l.range.start.line)).collect(); + assert_eq!(found, [(app_html.clone(), 0)]); + } + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); +} diff --git a/crates/ide-sdk/Cargo.toml b/crates/ide-sdk/Cargo.toml index b83b58065..a85096ad2 100644 --- a/crates/ide-sdk/Cargo.toml +++ b/crates/ide-sdk/Cargo.toml @@ -17,6 +17,7 @@ serde_json.workspace = true specta.workspace = true toml.workspace = true tracing.workspace = true +which = "8" zip = { version = "=2.4.2", default-features = false, features = ["deflate"] } [dev-dependencies] diff --git a/crates/ide-sdk/src/angular.rs b/crates/ide-sdk/src/angular.rs new file mode 100644 index 000000000..d7566418b --- /dev/null +++ b/crates/ide-sdk/src/angular.rs @@ -0,0 +1,90 @@ +//! Angular Language Server (`ngserver`): its package, where TypeScript and +//! `@angular/language-service` come from, and the project's Angular version. + +use crate::npm::package_dir; +use std::path::{Path, PathBuf}; + +pub(crate) const SERVER: &str = "angular-language-server"; +const PACKAGE: &str = "@angular/language-server"; + +/// `@angular/language-server`'s directory for an `ngserver` executable. +pub(crate) fn server_package(bin: &Path) -> Option { + package_dir(bin, PACKAGE) +} + +/// Why an `ngserver` whose package isn't found can't start: it loads TypeScript +/// and `@angular/language-service` from there. +pub(crate) fn missing_package_message(bin: &Path) -> String { + format!( + "Can't find the @angular/language-server package of {}, which brings the TypeScript and Angular \ + language service the server loads. Reinstall it in Settings › Languages & Frameworks › Language \ + Servers or with `npm i -g @angular/language-server`.", + bin.display() + ) +} + +/// Why a server installed in a directory whose path has a comma can't start. +pub(crate) fn comma_message(package: &str) -> String { + format!( + "The Angular Language Server can't load from {package}: its path has a comma. Install it in a \ + directory without one." + ) +} + +/// The version of the `@angular/core` that `dir` resolves (from its own or an +/// ancestor's `node_modules`, like Node). +pub(crate) fn core_version(dir: &Path) -> Option { + dir.ancestors().find_map(|a| { + let text = std::fs::read_to_string(a.join("node_modules/@angular/core/package.json")).ok()?; + let json: serde_json::Value = serde_json::from_str(&text).ok()?; + json.get("version")?.as_str().map(str::to_owned) + }) +} + +/// Arguments telling the server the project's Angular `version`: templates of +/// projects before 17 have no blocks (`@if`), before 18.1 no `@let`. +pub(crate) fn version_args(version: &str) -> Vec { + let mut parts = version.split(['.', '-', '+']).map(|p| p.parse::().ok()); + let (Some(Some(major)), Some(Some(minor))) = (parts.next(), parts.next()) else { return Vec::new() }; + // 0.0.0: a local build of Angular. + if major == 0 { + return Vec::new(); + } + let mut args = vec!["--angularCoreVersion".to_owned(), version.to_owned()]; + if major < 17 { + args.push("--disableBlockSyntax".into()); + } + if (major, minor) < (18, 1) { + args.push("--disableLetSyntax".into()); + } + args +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn template_syntax_follows_the_angular_version() { + let args = |v: &str| version_args(v).into_iter().skip(2).collect::>(); + assert_eq!(version_args("22.2.3")[..2], ["--angularCoreVersion", "22.2.3"]); + assert_eq!(args("22.0.0-next.1"), Vec::::new()); + assert_eq!(args("18.1.0"), Vec::::new()); + assert_eq!(args("18.0.5"), ["--disableLetSyntax"]); + assert_eq!(args("17.3.12"), ["--disableLetSyntax"]); + assert_eq!(args("16.2.0"), ["--disableBlockSyntax", "--disableLetSyntax"]); + assert!(version_args("0.0.0-PLACEHOLDER").is_empty()); + assert!(version_args("next").is_empty()); + } + + #[test] + fn core_version_is_found_in_an_ancestor() { + let tmp = tempfile::tempdir().unwrap(); + let core = tmp.path().join("node_modules/@angular/core"); + std::fs::create_dir_all(&core).unwrap(); + std::fs::write(core.join("package.json"), r#"{"name":"@angular/core","version":"19.2.1"}"#).unwrap(); + let app = tmp.path().join("apps/web"); + std::fs::create_dir_all(&app).unwrap(); + assert_eq!(core_version(&app).as_deref(), Some("19.2.1")); + } +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index b8b36fbb0..404305f9f 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -3,12 +3,13 @@ use crate::discover::{project_venv_python, workspace_tsdk}; use crate::exec::{path_with, search_path}; +use crate::{angular, godot, vue}; use crate::{bin_dir, exe, venv_of}; -use crate::{godot, vue}; use ide_core::settings::{LanguageServerSettings, Settings}; use ide_language::{LanguageConfig, LanguageServerConfig}; use ide_run::RunConfig; use serde_json::{Value, json}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; fn non_empty(s: &str) -> Option<&str> { @@ -300,6 +301,11 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect(); cfg.env.insert("PATH".into(), path_with(&dirs)); } + + // Last: it finds the server on the `PATH` set above. + if name == angular::SERVER { + configure_angular(cfg, root, server_root, s); + } } /// The servers `language` uses after Settings › Language Servers: the @@ -334,6 +340,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi args: Vec::new(), env: Default::default(), root_markers: ls.root_markers.clone(), + require_root_marker: false, initialization_options: None, settings: None, install_hint: Some(match ls.command.trim() { @@ -394,6 +401,38 @@ fn configure_vue_typescript(cfg: &mut LanguageServerConfig, root: &Path, server_ } } +/// ngserver loads TypeScript and `@angular/language-service` from probe +/// locations: its own package (it depends on both), then the project. A selected +/// TypeScript version goes first (`--tsdk`); the project's Angular version decides +/// the template syntax. Without its package the server can't start. +fn configure_angular(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { + // Found as the launcher finds it; missing, it asks to be installed. + let path = cfg.env.get("PATH").map_or_else(search_path, OsString::from); + let Ok(bin) = which::which_in(&cfg.command, Some(path), server_root) else { return }; + let Some(package) = angular::server_package(&bin) else { + cfg.launch_error = Some(angular::missing_package_message(&bin)); + return; + }; + let package = package.to_string_lossy().into_owned(); + // The server splits the list at commas. + if package.contains(',') { + cfg.launch_error = Some(angular::comma_message(&package)); + return; + } + // Launch the server whose package is probed, not another one on the path. + cfg.command = bin.to_string_lossy().into_owned(); + let project = server_root.to_string_lossy(); + let probes = if project.contains(',') { package } else { format!("{package},{project}") }; + cfg.args.extend(["--ngProbeLocations".into(), probes.clone(), "--tsProbeLocations".into(), probes]); + if let Some(lib) = tsdk(server_root, s).or_else(|| tsdk(root, s)) { + cfg.args.extend(["--tsdk".into(), lib.to_string_lossy().into_owned()]); + } + // The project root is an ancestor of the server's. + if let Some(version) = angular::core_version(server_root) { + cfg.args.extend(angular::version_args(&version)); + } +} + fn is_python_command(cmd: &str) -> bool { let name = Path::new(cmd).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = name.strip_suffix(".exe").unwrap_or(&name); diff --git a/crates/ide-sdk/src/exec.rs b/crates/ide-sdk/src/exec.rs index 4b0dd7af7..aa2296c67 100644 --- a/crates/ide-sdk/src/exec.rs +++ b/crates/ide-sdk/src/exec.rs @@ -14,9 +14,15 @@ pub fn search_path() -> OsString { let mut dirs: Vec = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); if let Some(home) = crate::home_dir() { - for rel in [".cargo/bin", ".local/bin", "go/bin", ".pyenv/shims", ".volta/bin"] { + for rel in [".cargo/bin", ".local/bin", "go/bin", ".pyenv/shims", ".bun/bin", ".npm-global/bin", ".volta/bin"] { dirs.push(home.join(rel)); } + // nvm: newest installed node's bin, as language servers are launched. + if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) { + let mut versions: Vec = entries.flatten().map(|e| e.path().join("bin")).collect(); + versions.sort(); + dirs.extend(versions.into_iter().rev()); + } } for d in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] { dirs.push(PathBuf::from(d)); diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 6e766b8c5..885735cf8 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -5,6 +5,7 @@ //! - applying the selected toolchains from settings to language-server launch //! configs, run configurations and new terminal sessions ([`apply`]). +mod angular; pub mod apply; mod discover; mod exec; @@ -14,6 +15,7 @@ pub mod jdtls; pub mod libraries; pub mod locate; pub mod lombok; +mod npm; pub mod python; mod vue; diff --git a/crates/ide-sdk/src/npm.rs b/crates/ide-sdk/src/npm.rs new file mode 100644 index 000000000..15a58ec2f --- /dev/null +++ b/crates/ide-sdk/src/npm.rs @@ -0,0 +1,36 @@ +//! Node-based language servers installed with npm: the package behind a command. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +/// `command` as given (a path) or found on `path`, npm shims included. +pub(crate) fn find_command(command: &str, path: &OsStr) -> Option { + let p = Path::new(command); + if p.is_absolute() { + return p.is_file().then(|| p.to_path_buf()); + } + let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe", ""] } else { &[""] }; + std::env::split_paths(path) + .flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}")))) + .find(|c| c.is_file()) +} + +/// The npm package an executable comes from: next to a `node_modules/.bin` shim +/// (the IDE's tools dir, a project), a Windows global shim (`%APPDATA%\npm`), a +/// Unix global `/bin` or a symlink into the package. +pub(crate) fn package_dir(bin: &Path, package: &str) -> Option { + let dir = bin.parent()?; + let mut candidates = vec![dir.join("node_modules").join(package)]; + if dir.file_name() == Some(OsStr::new(".bin")) + && let Some(node_modules) = dir.parent() + { + candidates.push(node_modules.join(package)); + } + if let Ok(real) = dunce::canonicalize(bin) { + candidates.extend(real.ancestors().filter(|a| a.ends_with(package)).map(Path::to_path_buf)); + } + if let Some(prefix) = dir.parent() { + candidates.push(prefix.join("lib").join("node_modules").join(package)); + } + candidates.into_iter().find(|c| c.join("package.json").is_file()) +} diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index ff7e157e1..9dee3aff8 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -27,6 +27,7 @@ fn server(name: &str) -> LanguageServerConfig { args: vec![], env: BTreeMap::new(), root_markers: vec![], + require_root_marker: false, initialization_options: None, settings: None, install_hint: None, @@ -600,6 +601,78 @@ fn vue_language_server_gets_a_typescript_with_the_language_service_api() { assert_eq!(cfg.args, [format!("--tsdk={}", lib.to_string_lossy())]); } +/// An npm shim for `name` in `dir`: `name.cmd` on Windows, an executable elsewhere. +fn npm_shim(dir: &Path, name: &str) -> std::path::PathBuf { + std::fs::create_dir_all(dir).unwrap(); + let shim = dir.join(if cfg!(windows) { format!("{name}.cmd") } else { name.to_owned() }); + std::fs::write(&shim, "").unwrap(); + #[cfg(unix)] + std::fs::set_permissions(&shim, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + shim +} + +#[test] +fn angular_language_server_probes_its_own_package_then_the_project() { + let tmp = tempfile::tempdir().unwrap(); + let tools = tmp.path().join("tools/node_modules"); + let project = tmp.path().join("app"); + let ngserver = npm_shim(&tools.join(".bin"), "ngserver"); + for (p, content) in [ + (tools.join("@angular/language-server/package.json"), "{}"), + (project.join("node_modules/@angular/core/package.json"), r#"{"version":"17.3.0"}"#), + (project.join("node_modules/typescript/lib/tsserver.js"), ""), + ] { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, content).unwrap(); + } + let configured = |command: &str, s: &Settings| { + let mut cfg = server("angular-language-server"); + cfg.command = command.into(); + cfg.args = vec!["--stdio".into()]; + configure_server(&mut cfg, &project, &project, s); + cfg + }; + let probes = format!("{},{}", tools.join("@angular/language-server").to_string_lossy(), project.to_string_lossy()); + let cfg = configured(&ngserver.to_string_lossy(), &Settings::default()); + assert_eq!( + cfg.args, + [ + "--stdio", + "--ngProbeLocations", + &probes, + "--tsProbeLocations", + &probes, + "--angularCoreVersion", + "17.3.0", + "--disableLetSyntax" + ] + ); + assert_eq!(cfg.launch_error, None); + + // The project's TypeScript when selected. + let mut s = Settings::default(); + s.languages.typescript.version = "workspace".into(); + let args = configured(&ngserver.to_string_lossy(), &s).args; + let tsdk = args.iter().position(|a| a == "--tsdk").map(|i| args[i + 1].clone()); + assert_eq!(tsdk, Some(project.join("node_modules/typescript/lib").to_string_lossy().into_owned())); + + // Found on the selected Node.js's PATH like the launcher finds it, and + // launched from there. + let mut s = Settings::default(); + s.languages.node.interpreter = tools.join(".bin/node").to_string_lossy().into_owned(); + let cfg = configured("ngserver", &s); + assert_eq!(dunce::canonicalize(&cfg.command).unwrap(), dunce::canonicalize(&ngserver).unwrap()); + assert!(cfg.args.contains(&probes)); + + // Without its package the server can't start; not installed, it asks to be. + let lone = npm_shim(&tmp.path().join("bin"), "ngserver"); + let cfg = configured(&lone.to_string_lossy(), &Settings::default()); + assert!(cfg.launch_error.is_some_and(|m| m.contains("@angular/language-server"))); + assert_eq!(cfg.args, ["--stdio"]); + let cfg = configured(&tmp.path().join("nowhere/ngserver").to_string_lossy(), &Settings::default()); + assert_eq!((cfg.launch_error, cfg.args), (None, vec!["--stdio".to_owned()])); +} + #[test] fn max_java_picks_an_older_jdk() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-sdk/src/vue.rs b/crates/ide-sdk/src/vue.rs index 6809485b7..639d2f1e7 100644 --- a/crates/ide-sdk/src/vue.rs +++ b/crates/ide-sdk/src/vue.rs @@ -1,6 +1,7 @@ //! Vue Language Server (Volar 3): the TypeScript it loads as a library, and the //! `@vue/typescript-plugin` that vtsls' tsserver hosts for it (hybrid mode). +use crate::npm::{find_command, package_dir}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; @@ -14,38 +15,6 @@ pub(crate) fn has_ts_api(lib: &Path) -> bool { lib.join("typescript.js").is_file() } -/// `command` as given (a path) or found on `path`, npm shims included. -fn find_command(command: &str, path: &OsStr) -> Option { - let p = Path::new(command); - if p.is_absolute() { - return p.is_file().then(|| p.to_path_buf()); - } - let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe", ""] } else { &[""] }; - std::env::split_paths(path) - .flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}")))) - .find(|c| c.is_file()) -} - -/// The npm package an executable comes from: next to a `node_modules/.bin` shim -/// (the IDE's tools dir, a project), a Windows global shim (`%APPDATA%\npm`), a -/// Unix global `/bin` or a symlink into the package. -fn package_dir(bin: &Path, package: &str) -> Option { - let dir = bin.parent()?; - let mut candidates = vec![dir.join("node_modules").join(package)]; - if dir.file_name() == Some(OsStr::new(".bin")) - && let Some(node_modules) = dir.parent() - { - candidates.push(node_modules.join(package)); - } - if let Ok(real) = dunce::canonicalize(bin) { - candidates.extend(real.ancestors().filter(|a| a.ends_with(package)).map(Path::to_path_buf)); - } - if let Some(prefix) = dir.parent() { - candidates.push(prefix.join("lib").join("node_modules").join(package)); - } - candidates.into_iter().find(|c| c.join("package.json").is_file()) -} - /// `@vue/language-server`'s directory for the `vue-language-server` command. pub(crate) fn server_package(command: &str, path: &OsStr) -> Option { package_dir(&find_command(command, path)?, PACKAGE) diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 591bb735d..c3b2e9fd4 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -257,6 +257,17 @@ pub static TOOLS: &[ToolSpec] = &[ homepage: "https://github.com/vuejs/language-tools", needs: &[], }, + ToolSpec { + name: "angular-language-server", + display: "Angular Language Server", + kind: LanguageServer, + commands: &["ngserver"], + // Brings the TypeScript and `@angular/language-service` it loads. + method: Method::Npm { packages: &["@angular/language-server"] }, + version: V, + homepage: "https://angular.dev/tools/language-service", + needs: &[], + }, ToolSpec { name: "graphql-lsp", display: "GraphQL Language Server", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 021753d31..2167734ac 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -72,6 +72,10 @@ fn everything_missing_on_an_empty_system() { assert_eq!(ty.command, "ty"); assert_eq!(ty.install_method.as_deref(), Some("pip (virtualenv)")); assert!(ty.languages.iter().any(|language| language == "Python")); + let angular = find(&list, "angular-language-server"); + assert_eq!(angular.command, "ngserver"); + assert_eq!(angular.install_method.as_deref(), Some("npm")); + assert_eq!(angular.languages, ["HTML", "TypeScript"]); // Servers without an installer are listed with their manual hint. let hls = find(&list, "haskell-language-server"); assert_eq!(hls.install_method, None); diff --git a/languages/html.toml b/languages/html.toml index d8f1e4357..03ed20a0a 100644 --- a/languages/html.toml +++ b/languages/html.toml @@ -7,6 +7,16 @@ indent = { tab-size = 2, insert-spaces = true } ui-mode = "HTML" tree-sitter = "html" +# Angular projects only: component templates. First, so that hover, rename and +# document symbols of templates come from it rather than plain HTML. +[[language-servers]] +name = "angular-language-server" +command = "ngserver" +args = ["--stdio", "--includeCompletionsWithSnippetText", "--includeAutomaticOptionalChainCompletions"] +root-markers = ["angular.json", "node_modules/@angular/core"] +require-root-marker = true +install-hint = "npm i -g @angular/language-server" + [[language-servers]] name = "vscode-html-language-server" command = "vscode-html-language-server" diff --git a/languages/typescript.toml b/languages/typescript.toml index c8dc5779d..536941b31 100644 --- a/languages/typescript.toml +++ b/languages/typescript.toml @@ -23,6 +23,17 @@ args = ["--stdio"] root-markers = ["tsconfig.json", "jsconfig.json", "package.json"] install-hint = "npm i -g typescript@\"<7\" typescript-language-server" +# Angular projects only: templates inline in components (`template: ...`), +# and their usages and renames in templates. Last: TypeScript's own hover, +# definition and symbols come first. +[[language-servers]] +name = "angular-language-server" +command = "ngserver" +args = ["--stdio", "--includeCompletionsWithSnippetText", "--includeAutomaticOptionalChainCompletions"] +root-markers = ["angular.json", "node_modules/@angular/core"] +require-root-marker = true +install-hint = "npm i -g @angular/language-server" + [debugger] adapter = "js-debug" command = "js-debug-adapter"