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{{ title }}\n{{ missing }}
\n";
+const NG_INLINE_TS: &str = "import { Component } from '@angular/core';\n\n@Component({\n selector: 'app-inline',\n template: `{{ count }} `,\n})\nexport class Inline {\n count = 1;\n}\n";
+
+/// The lines `edit` changes in `path`, in order.
+fn edited_lines(edit: &ide_core::WorkspaceEdit, path: &Path) -> 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"