feat(angular): Add the Angular Language Server (#147)

ngserver (@angular/language-server) runs next to vtsls and the HTML server in Angular projects, bringing completion, hover, definition, diagnostics, references and rename to external and inline component templates. A language server can now require a root marker (require-root-marker), so the Angular server only serves files below angular.json or node_modules/@angular/core and never hides the typescript-language-server fallback elsewhere. Attached servers keep definition order, and rename merges the edits of every server that accepts the position. Installable from the tools registry via npm.

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Co-authored-by: nntndfrk <[email protected]>
Цей коміт міститься в:
Mykhailo Smolinskyiтаnntndfrk authored and GitHub committed 2026-10-29 00:18:19 +08:00
1 джерело 4a15936acb
коміт 0184a9c395
23 files changed
+1091 -53

No files matched your search

Створено
+1
Переглянути файл
@@ -4372,6 +4372,7 @@ dependencies = [
"tempfile",
"toml 0.9.12+spec-1.1.0",
"tracing",
"which",
"zip 2.4.2",
]
+4
Переглянути файл
@@ -69,6 +69,10 @@ pub struct LanguageServerConfig {
/// Files whose presence marks a workspace root, e.g. `Cargo.toml`.
#[serde(default)]
pub root_markers: Vec<String>,
/// 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<toml::Value>,
+24 -1
Переглянути файл
@@ -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<String> {
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");
+273
Переглянути файл
@@ -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<i32> = 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<usize>,
}
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: `<p>{{ |x }}</p>` })"));
assert!(at_marker("@Component({\n template: '<p>|</p>',\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: '<p>|"));
}
#[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: |`<p></p>` })"));
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: '<p>|</p>' })"));
assert!(!at_marker("@Component({ template: `<p>${x}|</p>` })"));
assert!(at_marker("const s = `${'}'}`; @Component({ template: '<i>|</i>' })"));
assert!(!at_marker("const s = `${`${'a'}`}`; class A { b = '|'; }"));
}
#[test]
fn typescript_positions_go_to_angular_only_in_decorator_fields() {
let src = "@Component({ template: '<p></p>' })\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("</p>").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", ""));
}
}
+57
Переглянути файл
@@ -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<TextEdit> = 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<Location> {
@@ -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<TextEdit>| 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, )");
+14
Переглянути файл
@@ -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<LspManager>,
server: Arc<Server>,
@@ -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),
}
+5
Переглянути файл
@@ -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);
+19 -1
Переглянути файл
@@ -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<PathBuf> {
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();
+95 -16
Переглянути файл
@@ -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<DocText>,
servers: Vec<Arc<Server>>,
}
@@ -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<Arc<Server>> {
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<Server>| 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<CompletionList> {
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<Option<Hover>> {
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<Option<lsp::Hover>> =
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<Option<SignatureHelp>> {
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<Vec<Location>> {
let mut out = Outcome::new();
for t in targets {
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
match self
.call::<_, Option<lsp::GotoDefinitionResponse>>(
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<Location>| 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<Location> = w.first.into_iter().chain(w.second.into_iter().flatten()).collect();
let mut locs: Vec<Location> = 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<Option<Range>> {
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<Option<Range>> {
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<WorkspaceEdit> {
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<WorkspaceEdit> {
let query = || async {
let mut params = snap.pos_params(t, pos);
params["newName"] = json!(new_name);
let r: Option<lsp::WorkspaceEdit> = 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<Vec<InlayHint>> {
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 {
(
+199 -1
Переглянути файл
@@ -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: '<p>{{ title }}</p>' })\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(&[(
+114
Переглянути файл
@@ -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<serde_json::Valu
args: vec!["--stdio".into()],
env: Default::default(),
root_markers: vec!["package.json".into()],
require_root_marker: false,
initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()),
settings: None,
install_hint: None,
@@ -346,3 +349,114 @@ async fn vue_hybrid_mode_end_to_end() {
tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap();
}
}
const NG_APP_TS: &str = "import { Component } from '@angular/core';\n\n@Component({\n selector: 'app-root',\n templateUrl: './app.html',\n})\nexport class App {\n title = 'demo';\n}\n";
const NG_APP_HTML: &str = "<h1>{{ title }}</h1>\n<p>{{ missing }}</p>\n";
const NG_INLINE_TS: &str = "import { Component } from '@angular/core';\n\n@Component({\n selector: 'app-inline',\n template: `<b>{{ count }}</b>`,\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<u32> {
let mut lines: Vec<u32> = 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();
}
+1
Переглянути файл
@@ -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]
+90
Переглянути файл
@@ -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<PathBuf> {
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<String> {
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<String> {
let mut parts = version.split(['.', '-', '+']).map(|p| p.parse::<u32>().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::<Vec<_>>();
assert_eq!(version_args("22.2.3")[..2], ["--angularCoreVersion", "22.2.3"]);
assert_eq!(args("22.0.0-next.1"), Vec::<String>::new());
assert_eq!(args("18.1.0"), Vec::<String>::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"));
}
}
+40 -1
Переглянути файл
@@ -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);
+7 -1
Переглянути файл
@@ -14,9 +14,15 @@ pub fn search_path() -> OsString {
let mut dirs: Vec<PathBuf> =
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<PathBuf> = 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));
+2
Переглянути файл
@@ -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;
+36
Переглянути файл
@@ -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<PathBuf> {
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 `<prefix>/bin` or a symlink into the package.
pub(crate) fn package_dir(bin: &Path, package: &str) -> Option<PathBuf> {
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())
}
+73
Переглянути файл
@@ -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();
+1 -32
Переглянути файл
@@ -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<PathBuf> {
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 `<prefix>/bin` or a symlink into the package.
fn package_dir(bin: &Path, package: &str) -> Option<PathBuf> {
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<PathBuf> {
package_dir(&find_command(command, path)?, PACKAGE)
+11
Переглянути файл
@@ -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",
+4
Переглянути файл
@@ -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);
+10
Переглянути файл
@@ -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"
+11
Переглянути файл
@@ -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"