Файли
openidea/crates/ide-lsp/src/angular.rs
T
Mykhailo Smolinskyiтаnntndfrk 0184a9c395 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]>
2026-10-29 00:18:19 +08:00

274 рядки
10 KiB
Rust

//! 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", ""));
}
}