feat: Add Angular, HTML, and SCSS / Sass language servers (#1439)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Pavlo Basanets <Pavlo_Basanets@epam.com>
This commit is contained in:
authored and GitHub committed 2026-05-05 14:33:51 +02:00
1 parent e643e91cd7
commit 3122384822
53 files changed
+3280 -22

No files matched your search

+7
View File
@@ -564,6 +564,13 @@ jobs:
uses: leanprover/lean-action@v1
with:
lake-package-directory: test/resources/repos/lean4/test_repo
- name: Install npm deps for Angular test repo
shell: bash
run: |
if [ -d test/resources/repos/angular/test_repo ]; then
cd test/resources/repos/angular/test_repo
npm install --no-audit --no-fund --loglevel=warn
fi
- name: Cache language servers
id: cache-language-servers
uses: actions/cache@v3
+3
View File
@@ -28,6 +28,9 @@ Status of the `main` branch. Changes prior to the next official version change w
* Language Servers:
- Elixir (`elixir-tools/next-ls`): Fix deadlock in monorepo projects where `mix.exs` lives in a subdirectory. The server now searches immediate subdirectories when no `mix.exs` is found at the repository root. #1444
- Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415
- Add **Angular** (experimental) via a dual-server architecture: `@angular/language-server` (ngserver) handles standalone `.html` template files, while a companion `typescript-language-server` with `@angular/language-service` loaded as a tsserver plugin handles all `.ts` operations including inline templates. Provides type-aware navigation between templates and component classes. Requires Node.js, npm, and `@angular/core` installed in the project (`npm install` in the project root). Subsumes `typescript`+`html` for `.ts`/`.html` files when active; SCSS is not subsumed.
- Add **HTML** (experimental) using `vscode-html-language-server` from the `vscode-langservers-extracted` npm package. Provides in-file element/id symbols via documentSymbol; cross-file references are not meaningful for HTML. Also used as a companion server by the Angular LS for plain HTML documentSymbol support.
- Add **SCSS / Sass / CSS** (experimental) using [some-sass-language-server](https://github.com/wkillerud/some-sass). Handles `.scss`, `.sass`, and `.css` through one server, with full `@use`/`@forward` workspace-wide go-to-definition and find-references for variables, mixins, and functions across Sass files. The `.css` path uses the same `vscode-css-languageservice` engine that powers the standalone CSS LS; CSS feature toggles default off upstream and are flipped on at startup so symbols, hover, completion, and syntax-level diagnostics work for plain CSS as well.
# v1.2.0 (2026-04-27)
+1 -1
View File
@@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language
The underlying language servers are typically open-source projects or at least freely available for use.
When using Serena's language server backend, we provide **support for over 40 programming languages**, including
AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
AL, Angular, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
### The Serena JetBrains Plugin
@@ -29,6 +29,10 @@ We currently provide direct, out-of-the-box support for the programming language
Some languages require additional installations or setup steps, as noted.
* **AL**
* **Angular**
(experimental; requires Node.js + npm, plus `npm install` having been run in the project root so that `@angular/core`
is resolvable — without it, template-aware features silently return empty;
subsumes `typescript` and `html` for `.ts`/`.html` files, so do not also list those)
* **Ansible**
(experimental; requires Node.js and npm; automatically installs `@ansible/ansible-language-server`;
must be explicitly specified in the `languages` entry in the `project.yml`; requires `ansible` in PATH for full functionality)
@@ -71,6 +75,8 @@ Some languages require additional installations or setup steps, as noted.
(uses [shader-language-server](https://github.com/antaalt/shader-sense) (language `hlsl`); automatically downloaded;
on macOS, requires Rust toolchain for building from source;
note: reference search is not supported by this language server)
* **HTML**
(experimental; requires Node.js + npm)
* **Java**
* **JavaScript**
(supported via the TypeScript language server, i.e. use language `typescript` for both JavaScript and TypeScript)
@@ -107,6 +113,9 @@ Some languages require additional installations or setup steps, as noted.
(requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain)
* **Scala**
(requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP)
* **SCSS / Sass / CSS**
(experimental; requires Node.js + npm; uses [some-sass-language-server](https://github.com/wkillerud/some-sass) to handle
`.scss`, `.sass`, and `.css`)
* **Solidity**
(experimental; requires Node.js and npm; automatically installs `@nomicfoundation/solidity-language-server`;
works best with a `foundry.toml` or `hardhat.config.js` in the project root)
+76 -2
View File
@@ -227,8 +227,13 @@ ls_specific_settings:
This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`,
and by some additional wrappers that explicitly expose `ls_path`.
Common examples include: `ansible`, `bash`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `kotlin`, `lean4`, `luau`, `markdown`, `php`,
`php_phpactor`, `python`, `rust`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`.
Common examples include: `ansible`, `bash`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`,
`php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`.
Note: `angular` does **not** support `ls_path` — the Angular language server is part of a multi-process orchestration
(`ngserver` plus a companion TypeScript language server with the `@angular/language-service` plugin and an HTML
companion) where the dependency layout matters; use the version overrides documented in the Angular section below
to pin specific releases of the bundled stack.
If a language server supports `ls_path`, setting it bypasses Serena's managed download or install for that server.
In that case, any server-specific version or registry settings only apply when `ls_path` is not set.
@@ -243,6 +248,36 @@ Supported settings:
|---|---|---|
| `al_extension_version` | `18.0.2242655` | Override the AL VS Code extension version Serena downloads from the VS Code Marketplace. |
#### Angular
Serena uses `@angular/language-server` (`ngserver`) for the `angular` language key, orchestrated together with a
companion `typescript-language-server` (with `@angular/language-service` loaded as a tsserver plugin) and a
companion `vscode-html-language-server` for `.html` `documentSymbol`. This is an **experimental** language and
must be explicitly listed in `project.yml`; it is not auto-detected.
**Project requirements:**
- The project itself must have `@angular/core` installed (i.e. `npm install` must have been run in the project root,
or in a workspace root above it for monorepo layouts). Without it, `ngserver` reports every file as "not in an
Angular project" and template-aware features silently return empty.
- A `tsconfig.json` must be reachable at or above any opened `.ts` file.
- Do **not** also list `typescript` or `html` in `languages` when `angular` is active — Angular subsumes both
for `.ts` / `.html` files. SCSS is **not** subsumed; list `scss` separately if needed.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `angular_language_server_version` | `21.2.10` | Override the bundled `@angular/language-server` npm package version Serena installs. |
| `angular_language_service_version` | `21.2.10` | Override the bundled `@angular/language-service` tsserver plugin version. |
| `typescript_version` | `5.9.3` | Override the bundled `typescript` npm package version. Falls back to `ls_specific_settings.typescript.typescript_version` if unset. |
| `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` version. Falls back to `ls_specific_settings.typescript.typescript_language_server_version` if unset. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. Falls back to `ls_specific_settings.typescript.npm_registry` if unset. |
Notes:
- The HTML companion (`vscode-html-language-server`) is configured via `ls_specific_settings.html` — see the HTML section below.
- `ls_path` is not supported (see note above the AL section).
#### Ansible
Serena uses `@ansible/ansible-language-server` for the `ansible` language key.
@@ -496,6 +531,25 @@ ls_specific_settings:
renameSourceFolders: ["src", "lib"]
```
#### HTML
Serena uses `vscode-html-language-server` from Microsoft's `vscode-langservers-extracted` npm package for the
`html` language key. **Experimental** — must be explicitly listed in `project.yml`; not auto-detected. The HTML
LSP returns in-file element / id symbols via `documentSymbol`; cross-file `definition` / `references` are not
meaningful for HTML and are not exposed.
This same language server is also used as a tertiary companion by the Angular language server (see the Angular
section), since `ngserver` does not implement `textDocument/documentSymbol` for `.html` files.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `ls_path` | managed install | Override the `vscode-html-language-server` executable path. |
| `vscode_langservers_package` | `vscode-langservers-extracted` | npm package providing the binary. Set to `@t1ckbase/vscode-langservers-extracted` (or any other source) to use the actively-maintained 2026 fork. |
| `vscode_langservers_version` | `4.10.0` | Override the npm package version Serena installs when `ls_path` is not set. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. |
#### Java (`eclipse.jdt.ls`)
Java support has two installation modes:
@@ -790,6 +844,26 @@ Supported settings:
| `on_stale_lock` | `auto-clean` | How Serena handles stale Metals H2 database locks. Supported values: `auto-clean`, `warn`, `fail`. |
| `log_multi_instance_notice` | `true` | Log a notice when another Metals instance is detected. |
#### SCSS / Sass / CSS
Serena uses [`some-sass-language-server`](https://github.com/wkillerud/some-sass) for the `scss` language key.
**Experimental** — must be explicitly listed in `project.yml`; not auto-detected. Some Sass was chosen over the
generic `vscode-css-language-server` because it provides full workspace-wide `@use` / `@forward` go-to-definition
and find-references for variables, mixins, functions, and placeholders.
Handles `.scss`, `.sass`, and `.css`. The three are dispatched by the LSP language id (`scss`, `sass`, `css`) and
share the same engine; CSS feature toggles default to off upstream and Serena flips them on at startup so that
plain CSS gets symbols, definitions, references, hover, and completion. Lint diagnostics are deliberately left
off (the rules are opinionated about vendor prefixes / empty rules / etc.); only syntax-level diagnostics surface.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `ls_path` | managed install | Override the `some-sass-language-server` executable path. |
| `some_sass_version` | `2.3.8` | Override the npm package version Serena installs when `ls_path` is not set. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. |
#### Solidity
Serena uses `@nomicfoundation/solidity-language-server` for Solidity support.
+3
View File
@@ -142,10 +142,13 @@ Only the language servers listed below download or install additional dependenci
### npm Package Installs
- **Angular**: `@angular/language-server`, `@angular/language-service`, plus `typescript` and `typescript-language-server`
- **Ansible**: `@ansible/ansible-language-server`
- **Bash**: `bash-language-server`
- **Elm**: `@elm-tooling/elm-language-server`
- **HTML**: `vscode-langservers-extracted` (provides `vscode-html-language-server`)
- **PHP (`intelephense`)**: `intelephense`
- **SCSS / Sass / CSS**: `some-sass-language-server`
- **Solidity**: `@nomicfoundation/solidity-language-server`
- **TypeScript**: `typescript` and `typescript-language-server`
- **Vue**: `@vue/language-server`, plus `typescript` and `typescript-language-server`
+3
View File
@@ -349,6 +349,9 @@ markers = [
"solidity: language server running for Solidity (uses @nomicfoundation/solidity-language-server)",
"ansible: language server running for Ansible (uses @ansible/ansible-language-server)",
"msl: language server running for mSL (mIRC Scripting Language)",
"html: language server running for HTML (vscode-html-language-server, used as companion by Angular)",
"scss: language server running for SCSS / Sass / CSS (some-sass-language-server)",
"angular: language server running for Angular (uses @angular/language-server + @angular/language-service tsserver plugin)",
]
[tool.codespell]
+14 -12
View File
@@ -3,24 +3,26 @@ project_name: "project_name"
# list of languages for which language servers are started; choose from:
# al ansible bash clojure cpp
# cpp_ccls crystal csharp csharp_omnisharp dart
# elixir elm erlang fortran fsharp
# go groovy haskell haxe hlsl
# java json julia kotlin lean4
# lua luau markdown matlab msl
# nix ocaml pascal perl php
# php_phpactor powershell python python_jedi python_ty
# r rego ruby ruby_solargraph rust
# scala solidity swift systemverilog terraform
# toml typescript typescript_vts vue yaml
# zig
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# swift systemverilog terraform toml typescript
# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
@@ -0,0 +1,706 @@
"""
Angular Language Server integration for Serena.
Architecture (tri-server, modeled on the Vue LS dual-server pattern but with
an additional HTML companion because ngserver does not implement
``textDocument/documentSymbol`` for any file type):
ngserver (parent process, this class)
- handles .html templates: definition, references, hover, completion,
rename on template expressions (@if/@for/{{ }}/[prop]/(event))
- handles .ts references (ngserver aggregates template + TS usages in
one pass; typescript-language-server alone misses template usages
and often returns partial cross-file .ts references on Angular
projects where files aren't pre-opened)
- exposes Angular-specific custom requests
(IsInAngularProject, GetComponentsWithTemplateFile, ...)
- DOES NOT implement ``textDocument/documentSymbol`` at all — returns
-32601 for every .html and we do not route documentSymbol to it.
AngularTypeScriptServer (companion process, subclass of TypeScriptLanguageServer)
- handles .ts/.tsx/.cts/.mts documentSymbol, definition, hover, rename
- the @angular/language-service plugin is loaded into the companion
typescript-language-server via initializationOptions.plugins, which
makes tsserver Angular-aware for completions/hover on inline
templates. (Note: template *references* on .ts symbols are
empirically incomplete here; see ngserver routing above.)
VsCodeHtmlLanguageServer (companion process)
- handles .html ``textDocument/documentSymbol`` only: returns the
structural element tree (``<section>``, ``<app-*>``, ``<mat-*>``…)
which ngserver refuses to provide. Angular template directives like
``@if``/``@for`` are passed through as text content, which is fine —
this companion is only for structural outline.
Routing:
request_document_symbols(.ts) -> companion TS server
request_document_symbols(.html) -> companion HTML server
request_definition(.ts) -> companion TS server
request_definition(.html) -> ngserver
request_references(.ts) -> ngserver
request_references(.html) -> ngserver
request_hover(.ts) -> companion TS server
request_hover(.html) -> ngserver
request_rename_symbol_edit -> companion TS server (.ts), ngserver (.html)
Hard project requirements (failure modes if violated):
* tsconfig.json at the repository root (or above any opened .ts file).
* @angular/core resolvable from that tsconfig, i.e. ``npm install`` has been
run in the project. Without it, ngserver's `isInAngularProject` returns
false for every file and template features silently return empty.
"""
from __future__ import annotations
import json
import logging
import os
import pathlib
import shutil
import threading
from overrides import override
from solidlsp import ls_types
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command
from solidlsp.language_servers.typescript_language_server import (
TypeScriptLanguageServer,
prefer_non_node_modules_definition,
)
from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# Angular installs four interdependent npm packages into a single ``node_modules`` (npm
# hoists them so ngserver's plugin resolution works); the install-dir name encodes all
# four versions so a bump of any single one routes to a fresh subdir.
DEFAULT_ANGULAR_LANGUAGE_SERVER_VERSION = "21.2.10"
DEFAULT_ANGULAR_LANGUAGE_SERVICE_VERSION = "21.2.10"
DEFAULT_TYPESCRIPT_VERSION = "5.9.3"
DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3"
NGSERVER_BIN = "ngserver"
TSLS_BIN = "typescript-language-server"
class AngularTypeScriptServer(TypeScriptLanguageServer):
"""
Companion TypeScript Language Server configured with @angular/language-service
loaded as a tsserver plugin. The plugin makes tsserver understand Angular
decorators, inline templates, templateUrl/styleUrls navigation, and
cross-file references that span Angular templates.
"""
@classmethod
@override
def get_language_enum_instance(cls) -> Language:
return Language.TYPESCRIPT
def get_source_fn_matcher(self) -> FilenameMatcher:
# Use the Angular matcher so .html template files aren't filtered out of
# reference / search results when the companion is asked about them.
return Language.ANGULAR.get_source_fn_matcher()
class DependencyProvider(TypeScriptLanguageServer.DependencyProvider):
"""Dependency provider that returns a pre-resolved executable path.
The Angular LS install (run by ``AngularLanguageServer._setup_runtime_dependencies``)
already locates the ``typescript-language-server`` binary alongside ngserver,
so the companion does not need to perform another install lookup — it just
returns the path it was constructed with.
"""
def __init__(
self,
custom_settings: SolidLSPSettings.CustomLSSettings,
ls_resources_dir: str,
explicit_executable_path: str,
) -> None:
super().__init__(custom_settings, ls_resources_dir)
self._explicit_executable_path = explicit_executable_path
@override
def _get_or_install_core_dependency(self) -> str:
return self._explicit_executable_path
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
ext = os.path.splitext(relative_file_path)[1].lower()
if ext in (".ts", ".tsx", ".mts", ".cts"):
return "typescript"
if ext in (".js", ".jsx", ".mjs", ".cjs"):
return "javascript"
if ext in (".html", ".htm"):
return "html"
return "typescript"
def __init__(
self,
config: LanguageServerConfig,
repository_root_path: str,
solidlsp_settings: SolidLSPSettings,
angular_plugin_path: str,
tsdk_path: str,
ts_ls_executable_path: str,
):
self._angular_plugin_path = angular_plugin_path
self._custom_tsdk_path = tsdk_path
# Stored as instance state so the override survives across concurrent
# constructions of multiple AngularLanguageServer instances. The class
# attribute pattern this replaces was racy: two parallel constructors
# could see each other's value in the brief window between assignment
# and reset.
self._explicit_ts_ls_executable = ts_ls_executable_path
super().__init__(config, repository_root_path, solidlsp_settings)
@override
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(
self._custom_settings,
self._ls_resources_dir,
self._explicit_ts_ls_executable,
)
@override
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
params = super()._get_initialize_params(repository_absolute_path)
# Load @angular/language-service as a tsserver plugin via typescript-language-server's
# initializationOptions.plugins API (the same API Vue uses for @vue/typescript-plugin).
params["initializationOptions"] = {
"plugins": [
{
"name": "@angular/language-service",
"location": self._angular_plugin_path,
"languages": ["html"],
}
],
"tsserver": {
"path": self._custom_tsdk_path,
},
}
if "workspace" in params["capabilities"]:
params["capabilities"]["workspace"]["executeCommand"] = {"dynamicRegistration": True}
return params
@override
def _start_server(self) -> None:
def workspace_configuration_handler(params: dict) -> list:
items = params.get("items", [])
return [{} for _ in items]
self.server.on_request("workspace/configuration", workspace_configuration_handler)
super()._start_server()
class AngularLanguageServer(SolidLanguageServer):
"""
Angular Language Server — dual-process orchestration of ngserver + a companion
typescript-language-server with the @angular/language-service plugin loaded.
``ls_specific_settings["angular"]`` keys:
* ``angular_language_server_version``: version of ``@angular/language-server``
(default: ``21.2.10``).
* ``angular_language_service_version``: version of ``@angular/language-service``
(default: matches the language-server version).
* ``typescript_version``: TypeScript version installed for the companion
(default: ``5.9.3``).
* ``typescript_language_server_version``: typescript-language-server version
(default: ``5.1.3``).
* ``npm_registry``: optional alternative npm registry URL.
"""
NG_SERVER_READY_TIMEOUT = 10.0
TS_SERVER_READY_TIMEOUT = 10.0
HTML_SERVER_READY_TIMEOUT = 10.0
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
ng_executable, self._tsdk_path, self._ts_ls_executable, self._angular_plugin_path, self._install_dir = (
self._setup_runtime_dependencies(config, solidlsp_settings)
)
ng_cmd = [
ng_executable,
"--stdio",
"--tsProbeLocations",
os.path.join(self._install_dir, "node_modules"),
"--ngProbeLocations",
os.path.join(self._install_dir, "node_modules"),
]
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=ng_cmd, cwd=repository_root_path),
"angular",
solidlsp_settings,
)
self.server_ready = threading.Event()
self._ts_server: AngularTypeScriptServer | None = None
self._ts_server_started = False
self._html_server: VsCodeHtmlLanguageServer | None = None
self._html_server_started = False
@classmethod
@override
def supports_implementation_request(cls) -> bool:
# Angular templates and components are TypeScript code under the hood — ngserver
# delegates to tsserver, which supports textDocument/implementation for class
# members and interfaces (e.g. resolving an Angular lifecycle hook on a component
# back to its OnInit/OnDestroy interface declaration).
return True
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [
"node_modules",
"dist",
"build",
"coverage",
".angular",
".nx",
]
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
ext = os.path.splitext(relative_file_path)[1].lower()
if ext in (".ts", ".tsx", ".mts", ".cts"):
return "typescript"
if ext in (".js", ".jsx", ".mjs", ".cjs"):
return "javascript"
if ext in (".html", ".htm"):
return "html"
return "typescript"
@staticmethod
def _is_typescript_file(file_path: str) -> bool:
return os.path.splitext(file_path)[1].lower() in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
@staticmethod
def _is_html_template_file(file_path: str) -> bool:
return os.path.splitext(file_path)[1].lower() in (".html", ".htm")
@classmethod
def _setup_runtime_dependencies(
cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings
) -> tuple[str, str, str, str, str]:
"""
Install the Angular LS stack into the managed ls_resources_dir.
:return: tuple of (ngserver_path, tsdk_path, ts_ls_executable_path, angular_plugin_path, install_dir)
"""
assert shutil.which("node") is not None, "node is not installed or isn't in PATH. Please install NodeJS and try again."
assert shutil.which("npm") is not None, "npm is not installed or isn't in PATH. Please install npm and try again."
ng_settings = solidlsp_settings.get_ls_specific_settings(Language.ANGULAR)
ts_settings = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT)
ls_version = ng_settings.get("angular_language_server_version", DEFAULT_ANGULAR_LANGUAGE_SERVER_VERSION)
svc_version = ng_settings.get("angular_language_service_version", DEFAULT_ANGULAR_LANGUAGE_SERVICE_VERSION)
ts_version = ng_settings.get("typescript_version", ts_settings.get("typescript_version", DEFAULT_TYPESCRIPT_VERSION))
tsls_version = ng_settings.get(
"typescript_language_server_version",
ts_settings.get("typescript_language_server_version", DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION),
)
npm_registry = ng_settings.get("npm_registry", ts_settings.get("npm_registry"))
# Fully-versioned subdir so a bump of any single package cannot silently reuse
# stale companions in a shared node_modules.
ls_dirname = f"angular-lsp-{ls_version}-{svc_version}-{ts_version}-{tsls_version}"
install_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname)
ng_executable = os.path.join(install_dir, "node_modules", ".bin", NGSERVER_BIN)
ts_ls_executable = os.path.join(install_dir, "node_modules", ".bin", TSLS_BIN)
if os.name == "nt":
ng_executable += ".cmd"
ts_ls_executable += ".cmd"
tsdk_path = os.path.join(install_dir, "node_modules", "typescript", "lib")
angular_plugin_path = os.path.join(install_dir, "node_modules", "@angular", "language-service")
if not (os.path.exists(ng_executable) and os.path.exists(ts_ls_executable)):
log.info(
"Installing Angular LS stack: ngserver=%s, language-service=%s, typescript=%s, typescript-language-server=%s",
ls_version,
svc_version,
ts_version,
tsls_version,
)
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="typescript",
description="typescript (tsserver runtime, used by ngserver and the companion TS LS)",
command=build_npm_install_command("typescript", ts_version, npm_registry),
platform_id="any",
),
RuntimeDependency(
id="@angular/language-service",
description="Angular language service tsserver plugin",
command=build_npm_install_command("@angular/language-service", svc_version, npm_registry),
platform_id="any",
),
RuntimeDependency(
id="@angular/language-server",
description="Angular language server (ngserver binary)",
command=build_npm_install_command("@angular/language-server", ls_version, npm_registry),
platform_id="any",
),
RuntimeDependency(
id="typescript-language-server",
description="typescript-language-server (companion LS for .ts operations)",
command=build_npm_install_command("typescript-language-server", tsls_version, npm_registry),
platform_id="any",
),
]
)
deps.install(install_dir)
for path, label in (
(ng_executable, NGSERVER_BIN),
(ts_ls_executable, TSLS_BIN),
(angular_plugin_path, "@angular/language-service"),
(os.path.join(tsdk_path, "tsserverlibrary.js"), "typescript/lib/tsserverlibrary.js"),
):
if not os.path.exists(path):
raise FileNotFoundError(f"Expected {label} at {path} after install, but it was not found.")
return ng_executable, tsdk_path, ts_ls_executable, angular_plugin_path, install_dir
def _start_typescript_server(self) -> None:
try:
ts_config = LanguageServerConfig(code_language=Language.TYPESCRIPT, trace_lsp_communication=False)
log.info("Creating companion AngularTypeScriptServer")
self._ts_server = AngularTypeScriptServer(
config=ts_config,
repository_root_path=self.repository_root_path,
solidlsp_settings=self._solidlsp_settings,
angular_plugin_path=self._angular_plugin_path,
tsdk_path=self._tsdk_path,
ts_ls_executable_path=self._ts_ls_executable,
)
log.info("Starting companion TypeScript server")
self._ts_server.start()
log.info("Waiting for companion TypeScript server to be ready...")
if not self._ts_server.server_ready.wait(timeout=self.TS_SERVER_READY_TIMEOUT):
log.warning("Companion TS server ready timeout (%s s); proceeding anyway", self.TS_SERVER_READY_TIMEOUT)
self._ts_server.server_ready.set()
self._ts_server_started = True
log.info("Companion TypeScript server ready")
except Exception:
log.exception("Error starting companion TypeScript server")
self._ts_server = None
self._ts_server_started = False
raise
def _stop_typescript_server(self) -> None:
if self._ts_server is not None:
try:
log.info("Stopping companion TypeScript server")
self._ts_server.stop()
except Exception as e:
log.warning(f"Error stopping companion TypeScript server: {e}")
finally:
self._ts_server = None
self._ts_server_started = False
def _start_html_server(self) -> None:
"""Spawn vscode-html-language-server as a tertiary companion.
ngserver does not implement ``textDocument/documentSymbol`` (returns
-32601 for every .html file — both plain HTML and Angular templates).
The HTML companion provides the structural element outline the user
expects from documentSymbol on .html files. Failure to start it is
non-fatal: we log and fall back to returning an empty list.
"""
try:
html_config = LanguageServerConfig(code_language=Language.HTML, trace_lsp_communication=False)
log.info("Creating companion VsCodeHtmlLanguageServer")
self._html_server = VsCodeHtmlLanguageServer(
config=html_config,
repository_root_path=self.repository_root_path,
solidlsp_settings=self._solidlsp_settings,
)
log.info("Starting companion HTML server")
self._html_server.start()
if not self._html_server.server_ready.wait(timeout=self.HTML_SERVER_READY_TIMEOUT):
log.warning("Companion HTML server ready timeout (%s s); proceeding anyway", self.HTML_SERVER_READY_TIMEOUT)
self._html_server.server_ready.set()
self._html_server_started = True
log.info("Companion HTML server ready")
except Exception:
log.exception("Error starting companion HTML server; .html documentSymbol will return []")
self._html_server = None
self._html_server_started = False
def _stop_html_server(self) -> None:
if self._html_server is not None:
try:
log.info("Stopping companion HTML server")
self._html_server.stop()
except Exception as e:
log.warning(f"Error stopping companion HTML server: {e}")
finally:
self._html_server = None
self._html_server_started = False
def _find_angular_core_install(self) -> str | None:
"""Walk up from ``repository_root_path`` looking for ``node_modules/@angular/core``.
Handles monorepo layouts (Nx, yarn/pnpm workspaces) where ``node_modules`` is
hoisted to a workspace root above the activated sub-package. Stops walking at:
the filesystem root, a mount-point change, or a ``package.json`` that declares
``"workspaces"`` (the workspace root — no need to look further).
:return: absolute path to the discovered ``@angular/core/package.json``, or None.
"""
cur = pathlib.Path(self.repository_root_path).resolve()
try:
start_dev = cur.stat().st_dev
except OSError:
start_dev = None
steps = 0
for parent in [cur, *cur.parents]:
# Stop *before* probing across a mount-point change: a different
# st_dev typically means we've crossed a container/volume boundary
# and node_modules over there is unrelated.
if start_dev is not None:
try:
if parent.stat().st_dev != start_dev:
log.debug("Stopping @angular/core probe at %s after %d step(s) (mount-point change)", parent, steps)
break
except OSError:
break
steps += 1
candidate = parent / "node_modules" / "@angular" / "core" / "package.json"
if candidate.exists():
log.debug("Found @angular/core after %d step(s) at %s", steps, candidate)
return str(candidate)
workspace_pkg = parent / "package.json"
if workspace_pkg.exists():
try:
with open(workspace_pkg, encoding="utf-8") as f:
if "workspaces" in json.load(f):
log.debug("Stopping @angular/core probe at workspace root %s after %d step(s)", parent, steps)
break
except (OSError, ValueError) as e:
log.debug("Could not parse %s as JSON dict (%s); ignoring as workspace marker", workspace_pkg, e)
else:
log.debug("@angular/core probe walked %d ancestor(s) without finding an install or a workspace root", steps)
return None
def _check_angular_core_in_project(self) -> None:
"""Warn loudly if the project does not appear to have @angular/core installed."""
found = self._find_angular_core_install()
if found is None:
log.warning(
"Angular language server activated but @angular/core was not found in any "
"node_modules from %s upward. ngserver will report files as 'not in an "
"Angular project' and template-aware features will be disabled. Run "
"`npm install` in the workspace root to enable Angular features.",
self.repository_root_path,
)
else:
log.debug("Found @angular/core at %s", found)
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
root_uri = pathlib.Path(repository_absolute_path).as_uri()
params: dict = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
},
},
"initializationOptions": {
"ngProbeLocations": [os.path.join(self._install_dir, "node_modules")],
"tsProbeLocations": [os.path.join(self._install_dir, "node_modules")],
"forceStrictTemplates": False,
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return params # type: ignore[return-value]
@override
def _start_server(self) -> None:
self._check_angular_core_in_project()
# Start the companion TS server first so .ts operations are immediately available.
self._start_typescript_server()
# Start the HTML companion so .html documentSymbol works on first call.
self._start_html_server()
def do_nothing(_params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
def angular_project_loading_finish(_params: dict) -> None:
log.info("Angular project loading finished")
self.server_ready.set()
# Standard LSP boilerplate
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("$/progress", do_nothing)
self.server.on_request("client/registerCapability", lambda _params: None)
self.server.on_request("workspace/configuration", lambda _params: [{}])
# Angular-specific notifications (custom protocol from ngserver)
self.server.on_notification("angular/projectLoadingStart", do_nothing)
self.server.on_notification("angular/projectLoadingFinish", angular_project_loading_finish)
self.server.on_notification("angular/projectLanguageService", do_nothing)
# Companions are already running. If anything below fails, our caller never
# received an initialised handle and therefore can't invoke stop() — so we
# tear down both companions and any partially-started ngserver process here
# to avoid leaking Node processes.
try:
log.info("Starting Angular language server (ngserver)")
self.server.start()
init_params = self._get_initialize_params(self.repository_root_path)
init_response = self.server.send.initialize(init_params)
log.debug("Angular LS initialize response: %s", init_response)
self.server.notify.initialized({})
# ngserver loads the Angular compiler asynchronously after `initialized`. Wait briefly
# for projectLoadingFinish, then proceed regardless — operations queue inside ngserver.
# ngserver eagerly resolves the project once projectLoadingFinish fires; we previously
# ran a proactive .ts didOpen/didClose pass but empirical testing on real Angular
# projects (181 .ts / 85 .html) showed it added ~4s to cold start without improving
# first-query correctness or latency, so it has been removed.
if not self.server_ready.wait(timeout=self.NG_SERVER_READY_TIMEOUT):
log.info("Timeout waiting for ngserver project load; proceeding anyway")
self.server_ready.set()
except Exception:
self._stop_typescript_server()
self._stop_html_server()
try:
self.server.stop()
except Exception as e:
log.warning("Error stopping ngserver during startup-failure cleanup: %s", e)
raise
@override
def stop(self, shutdown_timeout: float = 5.0) -> None:
self._stop_typescript_server()
self._stop_html_server()
super().stop(shutdown_timeout)
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 5.0
@override
def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location:
return prefer_non_node_modules_definition(definitions)
# ---------------------------------------------------------------------
# Request routing — see module docstring for rationale per (op, ext) pair.
# ---------------------------------------------------------------------
@override
def _request_document_symbols(
self, relative_file_path: str, file_data: LSPFileBuffer | None
) -> list[SymbolInformation] | list[DocumentSymbol] | None:
if self._ts_server is not None and self._is_typescript_file(relative_file_path):
with self._ts_server.open_file(relative_file_path):
return self._ts_server._request_document_symbols(relative_file_path, file_data=None)
# ngserver returns -32601 for textDocument/documentSymbol on every .html file.
# Route to the HTML companion which gives the structural element tree
# (works on both plain HTML like index.html and Angular templates).
if self._is_html_template_file(relative_file_path):
if self._html_server is not None and self._html_server_started:
with self._html_server.open_file(relative_file_path):
return self._html_server._request_document_symbols(relative_file_path, file_data=None)
log.debug("HTML companion unavailable for %s; returning None", relative_file_path)
return None
return super()._request_document_symbols(relative_file_path, file_data)
@override
def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
if self._ts_server is not None and self._is_typescript_file(relative_file_path):
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_definition(relative_file_path, line, column)
# HTML templates: ngserver knows how to resolve template -> component
return super().request_definition(relative_file_path, line, column)
# request_references is intentionally not overridden: ngserver (the parent
# process) handles both .ts and .html references and returns the full set,
# whereas the TS companion under-reports because it only sees pre-opened
# files. See module docstring routing table.
@override
def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None:
if self._ts_server is not None and self._is_typescript_file(relative_file_path):
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name)
return super().request_rename_symbol_edit(relative_file_path, line, column, new_name)
@override
def request_hover(
self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None
) -> ls_types.Hover | None:
if self._ts_server is not None and self._is_typescript_file(relative_file_path):
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_hover(relative_file_path, line, column, file_buffer=file_buffer)
return super().request_hover(relative_file_path, line, column, file_buffer=file_buffer)
@override
def request_implementation(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
# ngserver does not advertise textDocument/implementation (returns -32601);
# the companion typescript-language-server (with the @angular/language-service
# plugin loaded) does, since the underlying tsserver implements it for
# interface→implementation, abstract→concrete, etc. Keep this routed even
# for .html paths because the LSP method is meaningless on plain HTML.
if self._ts_server is not None and self._is_typescript_file(relative_file_path):
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_implementation(relative_file_path, line, column)
log.debug(
"request_implementation called on non-TS path %s; ngserver does not advertise the LSP method "
"and the request is meaningless on plain HTML — returning []",
relative_file_path,
)
return []
@override
def request_text_document_diagnostics(
self,
relative_file_path: str,
start_line: int = 0,
end_line: int = -1,
min_severity: int = 4,
) -> list[ls_types.Diagnostic]:
# ngserver does not handle pull diagnostics for .ts files in the way tsserver
# does — it produces template diagnostics on .html attached via templateUrl,
# but for component classes we want the TS error stream, which lives in the
# companion typescript-language-server.
if self._ts_server is not None and self._is_typescript_file(relative_file_path):
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_text_document_diagnostics(
relative_file_path, start_line=start_line, end_line=end_line, min_severity=min_severity
)
return super().request_text_document_diagnostics(
relative_file_path, start_line=start_line, end_line=end_line, min_severity=min_severity
)
@@ -0,0 +1,239 @@
"""
Provides SCSS / Sass / CSS instantiation of the LanguageServer class using the
``some-sass-language-server`` npm package (https://github.com/wkillerud/some-sass).
Some Sass is the dedicated, actively maintained SCSS LSP. It also accepts plain
``.css`` files via the same ``vscode-css-languageservice`` engine that powers
Microsoft's standalone CSS LS — so Serena routes ``.scss`` / ``.sass`` / ``.css``
through this single server.
Compared to the generic ``vscode-css-language-server`` server, Some Sass also
provides full ``@use`` / ``@forward`` workspace navigation (cross-file
go-to-definition and find-references for mixins, functions, variables,
placeholders), SassDoc, and the indented Sass syntax.
Caveats:
* Cross-file Sass navigation requires the workspace to be configured (the LS
scans the project root after initialization).
* For ``.css`` files, every ``somesass.css.*.enabled`` toggle defaults to
``false`` upstream; we flip them on at initialization. See
``SOMESASS_CSS_FEATURES`` below for the full set.
* Language is registered as experimental.
"""
from __future__ import annotations
import logging
import os
import pathlib
import shutil
import threading
from overrides import override
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
DEFAULT_PACKAGE_VERSION = "2.3.8"
LS_BIN_NAME = "some-sass-language-server"
# Every ``somesass.css.*.enabled`` toggle (per the upstream package.json
# ``contributes.configuration``) defaults to false in some-sass — meaning a request
# for ``.css`` files would be hard-gated off at the top of every handler. Flipping
# the full set on at initialization is what makes plain CSS usable through Some Sass.
# We deliberately leave ``diagnostics.lint.enabled`` off because the lint rules are
# opinionated (vendor prefixes, empty rules, etc.) and would be noisy on user code.
SOMESASS_CSS_FEATURES: dict[str, dict[str, object]] = {
"codeAction": {"enabled": True},
"colors": {"enabled": True},
"completion": {"enabled": True},
"definition": {"enabled": True},
"diagnostics": {"enabled": True, "lint": {"enabled": False}},
"documentSymbols": {"enabled": True},
"foldingRanges": {"enabled": True},
"highlights": {"enabled": True},
"hover": {"enabled": True},
"links": {"enabled": True},
"references": {"enabled": True},
"rename": {"enabled": True},
"selectionRanges": {"enabled": True},
"signatureHelp": {"enabled": True},
"workspaceSymbol": {"enabled": True},
}
SOMESASS_INIT_OPTIONS: dict[str, object] = {
# See https://wkillerud.github.io/some-sass/user-guide/settings.html
"somesass": {
"css": SOMESASS_CSS_FEATURES,
"workspace": {"loadPaths": []},
"suggest": {"suggestFromUseOnly": False},
},
}
class SomeSassLanguageServer(SolidLanguageServer):
"""
SCSS / Sass language server (Some Sass by wkillerud).
``ls_specific_settings["scss"]`` keys:
* ``some_sass_version``: version of ``some-sass-language-server`` to install
(default: ``2.3.8``).
* ``npm_registry``: optional alternative npm registry URL.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
super().__init__(
config,
repository_root_path,
None,
"scss",
solidlsp_settings,
)
self.server_ready = threading.Event()
@override
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
# Some Sass switches behavior off the LSP languageId in its
# ``languageConfiguration()`` selector, picking the matching
# ``LanguageServerConfiguration.{css,sass,scss}`` slice. Sending the wrong id
# for plain CSS would route the file to the SCSS parser and skip the
# ``somesass.css.*`` feature gate entirely.
ext = os.path.splitext(relative_file_path)[1].lower()
if ext == ".sass":
return "sass"
if ext == ".css":
return "css"
return "scss"
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "dist", "build", "coverage"]
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
assert shutil.which("node") is not None, "node is not installed or isn't in PATH. Please install NodeJS and try again."
assert shutil.which("npm") is not None, "npm is not installed or isn't in PATH. Please install npm and try again."
package_version = self._custom_settings.get("some_sass_version", DEFAULT_PACKAGE_VERSION)
npm_registry = self._custom_settings.get("npm_registry")
ls_dirname = f"some-sass-{package_version}"
install_dir = os.path.join(self._ls_resources_dir, ls_dirname)
executable_path = os.path.join(install_dir, "node_modules", ".bin", LS_BIN_NAME)
if os.name == "nt":
executable_path += ".cmd"
if not os.path.exists(executable_path):
expected_version = f"some-sass-language-server@{package_version}"
log.info("Installing %s...", expected_version)
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="some-sass-language-server",
description="Some Sass language server (SCSS / Sass / CSS)",
command=build_npm_install_command("some-sass-language-server", package_version, npm_registry),
platform_id="any",
),
]
)
deps.install(install_dir)
if not os.path.exists(executable_path):
raise FileNotFoundError(
f"{LS_BIN_NAME} executable not found at {executable_path}; "
f"npm install of some-sass-language-server@{package_version} did not produce the expected binary."
)
return executable_path
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params: dict = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
},
},
"initializationOptions": SOMESASS_INIT_OPTIONS,
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params # type: ignore[return-value]
@staticmethod
def _handle_workspace_configuration(params: dict) -> list[dict]:
# Some Sass calls workspace/configuration after init to fetch ``somesass`` and
# ``editor`` sections. The LSP contract is one entry per requested item, in order.
# We respond with our pinned somesass slice for the somesass section and an empty
# dict for everything else (currently only ``editor``).
items = params.get("items", []) if isinstance(params, dict) else []
somesass_section = SOMESASS_INIT_OPTIONS["somesass"]
result: list[dict] = []
for item in items:
section = (item or {}).get("section") if isinstance(item, dict) else None
if section == "somesass":
result.append(somesass_section) # type: ignore[arg-type]
else:
# ``editor`` is the only other section Some Sass currently asks for; an unknown
# section means upstream added a new config slice we should consider supplying.
if section not in (None, "editor"):
log.debug("workspace/configuration: unknown section %r; responding with empty dict", section)
result.append({})
return result or [{}]
def _start_server(self) -> None:
def do_nothing(_params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("$/progress", do_nothing)
self.server.on_request("client/registerCapability", lambda _params: None)
self.server.on_request("workspace/configuration", self._handle_workspace_configuration)
log.info("Starting some-sass-language-server")
self.server.start()
init_params = self._get_initialize_params(self.repository_root_path)
init_response = self.server.send.initialize(init_params)
log.debug("Some Sass LS initialize response: %s", init_response)
assert "completionProvider" in init_response["capabilities"], "Some Sass LSP did not advertise completionProvider"
self.server.notify.initialized({})
self.server_ready.set()
@@ -0,0 +1,178 @@
"""
Provides HTML-specific instantiation of the LanguageServer class using
``vscode-html-language-server`` from the ``vscode-langservers-extracted`` npm
package (the same language server VS Code uses).
By default, the original :pypi:`vscode-langservers-extracted` package is installed
(stable, widely used). Users can opt into the actively-maintained 2026 fork
``@t1ckbase/vscode-langservers-extracted`` (or any other source) by overriding
``vscode_langservers_package`` and ``vscode_langservers_version`` in
``ls_specific_settings.html``. Both packages expose the same ``vscode-html-language-server``
binary name under ``node_modules/.bin``, so no code changes are needed to switch.
Caveats:
* HTML LSP provides in-file element/id symbols via ``documentSymbol``;
cross-file ``find references`` / ``go to definition`` is not meaningful for HTML.
* Language is registered as experimental.
"""
from __future__ import annotations
import logging
import os
import pathlib
import shutil
import threading
from overrides import override
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
DEFAULT_PACKAGE_NAME = "vscode-langservers-extracted"
DEFAULT_PACKAGE_VERSION = "4.10.0"
LS_BIN_NAME = "vscode-html-language-server"
class VsCodeHtmlLanguageServer(SolidLanguageServer):
"""
HTML language server (Microsoft, extracted from VS Code).
``ls_specific_settings["html"]`` keys:
* ``vscode_langservers_package``: npm package providing the binary
(default: ``vscode-langservers-extracted``).
* ``vscode_langservers_version``: version of the package to install
(default: ``4.10.0``).
* ``npm_registry``: optional alternative npm registry URL.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
super().__init__(
config,
repository_root_path,
None,
"html",
solidlsp_settings,
)
self.server_ready = threading.Event()
@override
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "dist", "build", "coverage"]
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
assert shutil.which("node") is not None, "node is not installed or isn't in PATH. Please install NodeJS and try again."
assert shutil.which("npm") is not None, "npm is not installed or isn't in PATH. Please install npm and try again."
package_name = self._custom_settings.get("vscode_langservers_package", DEFAULT_PACKAGE_NAME)
package_version = self._custom_settings.get("vscode_langservers_version", DEFAULT_PACKAGE_VERSION)
npm_registry = self._custom_settings.get("npm_registry")
# Versioned subdir keeps a bump from silently reusing a stale binary. The dir
# is also isolated from any other LS that defaults to the same npm package,
# since users may override ``vscode_langservers_package`` independently per language.
ls_dirname = f"vscode-langservers-html-{package_version}"
install_dir = os.path.join(self._ls_resources_dir, ls_dirname)
executable_path = os.path.join(install_dir, "node_modules", ".bin", LS_BIN_NAME)
if os.name == "nt":
executable_path += ".cmd"
if not os.path.exists(executable_path):
expected_version = f"{package_name}@{package_version}"
log.info("Installing %s for HTML language server...", expected_version)
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id=package_name,
description=f"{package_name} (provides {LS_BIN_NAME})",
command=build_npm_install_command(package_name, package_version, npm_registry),
platform_id="any",
),
]
)
deps.install(install_dir)
if not os.path.exists(executable_path):
raise FileNotFoundError(
f"{LS_BIN_NAME} executable not found at {executable_path}; "
f"npm install of {package_name}@{package_version} did not produce the expected binary."
)
return executable_path
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params: dict = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
},
},
"initializationOptions": {
"embeddedLanguages": {"css": True, "javascript": True},
"handledSchemas": ["file"],
"provideFormatter": False,
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params # type: ignore[return-value]
def _start_server(self) -> None:
def do_nothing(_params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("$/progress", do_nothing)
self.server.on_request("client/registerCapability", lambda _params: None)
self.server.on_request("workspace/configuration", lambda _params: [{}])
log.info("Starting vscode-html-language-server")
self.server.start()
init_params = self._get_initialize_params(self.repository_root_path)
init_response = self.server.send.initialize(init_params)
log.debug("HTML LS initialize response: %s", init_response)
# Sanity-check key capabilities; HTML LSP always provides documentSymbol + completion
assert "completionProvider" in init_response["capabilities"], "HTML LSP did not advertise completionProvider"
self.server.notify.initialized({})
self.server_ready.set()
@@ -21,7 +21,7 @@ from solidlsp.language_servers.typescript_language_server import (
TypeScriptLanguageServer,
prefer_non_node_modules_definition,
)
from solidlsp.ls import LSPFileBuffer, SolidLanguageServer
from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_types import Location
@@ -54,12 +54,26 @@ class VueTypeScriptServer(TypeScriptLanguageServer):
return Language.VUE.get_source_fn_matcher()
class DependencyProvider(TypeScriptLanguageServer.DependencyProvider):
override_ts_ls_executable: str | None = None
"""Dependency provider that returns a pre-resolved executable path.
The Vue LS install (run by ``VueLanguageServer._setup_runtime_dependencies``)
already locates the ``typescript-language-server`` binary alongside the Vue
language server, so the companion does not need to perform another install
lookup — it just returns the path it was constructed with.
"""
def __init__(
self,
custom_settings: SolidLSPSettings.CustomLSSettings,
ls_resources_dir: str,
explicit_executable_path: str,
) -> None:
super().__init__(custom_settings, ls_resources_dir)
self._explicit_executable_path = explicit_executable_path
@override
def _get_or_install_core_dependency(self) -> str:
if self.override_ts_ls_executable is not None:
return self.override_ts_ls_executable
return super()._get_or_install_core_dependency()
return self._explicit_executable_path
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
@@ -90,9 +104,21 @@ class VueTypeScriptServer(TypeScriptLanguageServer):
):
self._vue_plugin_path = vue_plugin_path
self._custom_tsdk_path = tsdk_path
VueTypeScriptServer.DependencyProvider.override_ts_ls_executable = ts_ls_executable_path
# Stored as instance state so the override survives across concurrent
# constructions of multiple VueLanguageServer instances. The class
# attribute pattern this replaces was racy: two parallel constructors
# could see each other's value in the brief window between assignment
# and reset.
self._explicit_ts_ls_executable = ts_ls_executable_path
super().__init__(config, repository_root_path, solidlsp_settings)
VueTypeScriptServer.DependencyProvider.override_ts_ls_executable = None
@override
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(
self._custom_settings,
self._ls_resources_dir,
self._explicit_ts_ls_executable,
)
@override
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
+58
View File
@@ -155,6 +155,33 @@ class Language(str, Enum):
Must be explicitly specified in project.yml. Requires Node.js and npm.
Requires ``ansible`` in PATH for full functionality.
"""
HTML = "html"
"""HTML language server (experimental) using vscode-html-language-server from
Microsoft's vscode-langservers-extracted npm package. Supports *.html and *.htm files.
Must be explicitly specified in project.yml. Requires Node.js and npm.
Note: HTML LSP provides in-file element/id symbols only; cross-file references
are not meaningful for HTML. Also used as a companion server by Angular LS for
plain HTML documentSymbol support.
"""
SCSS = "scss"
"""SCSS / Sass / CSS language server (experimental) using some-sass-language-server
(https://github.com/wkillerud/some-sass). Handles *.scss, *.sass, and *.css.
Must be explicitly specified in project.yml. Requires Node.js and npm.
Provides full @use/@forward workspace navigation across SCSS files; CSS support
relies on the same vscode-css-languageservice engine and is enabled at startup
via the somesass.css.* feature toggles (which default to off upstream).
"""
ANGULAR = "angular"
"""Angular Language Server (experimental) using the official @angular/language-server
(ngserver). Supports *.ts and *.html files (Angular templates can be external or inline).
Understands Angular template syntax (*ngIf, [prop], (event), {{ interpolation }},
@if/@for blocks, etc.) and provides type-aware navigation between templates and
component classes — which the plain HTML and TypeScript LSPs cannot.
Requires Node.js, npm, and a valid Angular workspace (angular.json or Nx project.json
at the repository root). When activated, do not also enable typescript or html in
project.yml — Angular LS supersedes both for Angular projects.
Must be explicitly specified in project.yml.
"""
@classmethod
def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]:
@@ -185,6 +212,9 @@ class Language(str, Enum):
self.GROOVY,
self.CPP_CCLS,
self.SOLIDITY,
self.HTML,
self.SCSS,
self.ANGULAR,
}
def __str__(self) -> str:
@@ -344,6 +374,22 @@ class Language(str, Enum):
return FilenameMatcher("*.yaml", "*.yml")
case self.MSL:
return FilenameMatcher("*.mrc")
case self.HTML:
return FilenameMatcher("*.html", "*.htm")
case self.SCSS:
# *.css is handled by the same engine (vscode-css-languageservice) that powers
# Microsoft's CSS LS, so we route plain CSS through Some Sass too. The CSS feature
# toggles default off upstream and are flipped on at initialization time.
return FilenameMatcher("*.scss", "*.sass", "*.css")
case self.ANGULAR:
# Angular templates can be standalone .html files or inline templates
# within .ts component files; the dual-server architecture handles both.
# SCSS / styles are deliberately NOT subsumed — use Language.SCSS for those.
path_patterns = ["*.html", "*.htm"]
for prefix in ["c", "m", ""]:
for postfix in ["x", ""]:
path_patterns.append(f"*.{prefix}ts{postfix}")
return FilenameMatcher(*path_patterns)
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -575,6 +621,18 @@ class Language(str, Enum):
from solidlsp.language_servers.msl_language_server import MslLanguageServer
return MslLanguageServer
case self.HTML:
from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer
return VsCodeHtmlLanguageServer
case self.SCSS:
from solidlsp.language_servers.some_sass_language_server import SomeSassLanguageServer
return SomeSassLanguageServer
case self.ANGULAR:
from solidlsp.language_servers.angular_language_server import AngularLanguageServer
return AngularLanguageServer
case _:
raise ValueError(f"Unhandled language: {self}")
+4
View File
@@ -277,6 +277,9 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = {
Language.PYTHON_TY: [pytest.mark.python],
Language.RUST: [pytest.mark.rust],
Language.TYPESCRIPT: [pytest.mark.typescript],
Language.ANGULAR: [pytest.mark.angular],
Language.HTML: [pytest.mark.html],
Language.SCSS: [pytest.mark.scss],
}
@@ -349,6 +352,7 @@ def languages_supporting_implementation(*languages: Language) -> list[Language]:
_VERIFIED_IMPLEMENTATION_LANGUAGES = {
Language.ANGULAR,
Language.CSHARP,
Language.GO,
Language.JAVA,
@@ -0,0 +1,5 @@
node_modules
.angular
dist
out-tsc
.angular-install.lock
@@ -0,0 +1,24 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"serena-test-app": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/serena-test-app",
"index": "src/index.html",
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json"
}
}
}
}
}
}
+134
View File
@@ -0,0 +1,134 @@
{
"name": "serena-angular-test-repo",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "serena-angular-test-repo",
"version": "0.0.0",
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.6.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"typescript": "~5.9.0"
}
},
"node_modules/@angular/common": {
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.10.tgz",
"integrity": "sha512-WLyi/CRLtgALg2mmaqIuKuPnE4i+8PGt/uuz26pVqx+ASh28/TWr5KSCAMomgxEc8kt4OE7lopoQsTihrQCfEw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "21.2.10",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.10.tgz",
"integrity": "sha512-IrgdFuzzD7NTK3WQaSfowjAPxPbnTqsgR92NsOs5ZaWu3RgLl21dHThNc0BK1KwVwppLUSWmD4qePbcLW71VzQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@angular/core": {
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.10.tgz",
"integrity": "sha512-uxH+mbPiCE7rInWKYOPe9Ytas97+mFM6FhFORoN234yBK3b8he+iDuxX6dsbhEFCxhRmfS6hLxe7BdLY6U6kIA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/compiler": "21.2.10",
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0"
},
"peerDependenciesMeta": {
"@angular/compiler": {
"optional": true
},
"zone.js": {
"optional": true
}
}
},
"node_modules/@angular/platform-browser": {
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.10.tgz",
"integrity": "sha512-5WMoHGU8BOV3eO9h3vGMIUDPf+3SHis7+X2dHKMtKfFBUtiO8m/lq2x3PzkkKj1782i7KYt92EqPHuADd/eWOw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/animations": "21.2.10",
"@angular/common": "21.2.10",
"@angular/core": "21.2.10"
},
"peerDependenciesMeta": {
"@angular/animations": {
"optional": true
}
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/zone.js": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
"license": "MIT"
}
}
}
@@ -0,0 +1,18 @@
{
"name": "serena-angular-test-repo",
"version": "0.0.0",
"private": true,
"description": "Minimal Angular workspace fixture used by Serena language-server tests.",
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.6.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"typescript": "~5.9.0"
}
}
@@ -0,0 +1,21 @@
<section>
<h1>{{ title() | exclaim }}</h1>
<p>{{ greeting() }}</p>
<input
type="text"
[value]="userName()"
(input)="setName($any($event.target).value)"
placeholder="Enter your name"
/>
@if (items().length > 0) {
<ul>
@for (item of items(); track item) {
<app-item-card [label]="item"></app-item-card>
}
</ul>
} @else {
<p>No items.</p>
}
</section>
@@ -0,0 +1,26 @@
import { Component, signal } from '@angular/core';
import { GreetingService } from './greeting.service';
import { ItemCardComponent } from './item-card.component';
import { ExclaimPipe } from './exclaim.pipe';
@Component({
selector: 'app-root',
standalone: true,
imports: [ItemCardComponent, ExclaimPipe],
templateUrl: './app.component.html',
})
export class AppComponent {
readonly title = signal('Serena Angular Test');
readonly userName = signal('');
readonly items = signal<string[]>(['alpha', 'beta', 'gamma']);
constructor(private readonly greetings: GreetingService) {}
greeting(): string {
return this.greetings.greet(this.userName() || undefined);
}
setName(name: string): void {
this.userName.set(name);
}
}
@@ -0,0 +1,6 @@
<!-- Template path: ngserver flags the reference to ``undefinedSignal()`` since
it has no matching field on DiagnosticsSampleComponent. The standalone .html
diagnostic exercises the Angular template compiler, not plain-HTML validation. -->
<section>
<h1>{{ undefinedSignal() }}</h1>
</section>
@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
// Two diagnostic sources in one fixture:
// * the type error on ``count`` is reported by tsserver via the Angular plugin
// when diagnosing this .ts file directly.
// * the ``undefinedSignal()`` reference in diagnostics_sample.html is reported
// by ngserver when diagnosing the template attached via ``templateUrl``.
@Component({
selector: 'app-diagnostics-sample',
standalone: true,
templateUrl: './diagnostics_sample.html',
})
export class DiagnosticsSampleComponent {
readonly count: number = 'not-a-number';
}
@@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'exclaim',
standalone: true,
})
export class ExclaimPipe implements PipeTransform {
transform(value: string, count: number = 1): string {
return value + '!'.repeat(count);
}
}
@@ -0,0 +1,3 @@
export interface Greeter {
greet(name?: string): string;
}
@@ -0,0 +1,11 @@
import { Injectable } from '@angular/core';
import { Greeter } from './greeter.interface';
@Injectable({ providedIn: 'root' })
export class GreetingService implements Greeter {
private readonly defaultName = 'World';
greet(name?: string): string {
return `Hello, ${name ?? this.defaultName}!`;
}
}
@@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-item-card',
standalone: true,
template: `
<li class="item-card" [attr.data-label]="label">
<strong>{{ label }}</strong>
</li>
`,
})
export class ItemCardComponent {
@Input() label: string = '';
formatLabel(): string {
return this.label.trim();
}
}
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Serena Angular Test</title>
</head>
<body>
<app-root></app-root>
</body>
</html>
@@ -0,0 +1,4 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch((err) => console.error(err));
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.html"]
}
@@ -0,0 +1,25 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>About — Serena HTML Test</title>
<link rel="stylesheet" href="styles/main.css">
</head>
<body>
<header id="page-header">
<h1 id="about-title">About</h1>
<nav id="main-nav">
<a href="index.html">Home</a>
</nav>
</header>
<main id="page-main">
<article id="about-article">
<h2>About this repo</h2>
<p>Used by the Serena language server tests.</p>
</article>
</main>
</body>
</html>
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<!-- Diagnostics smoke fixture — vscode-html-language-server does not emit
diagnostics for HTML structure; this file just gives the LS something to
chew on so the diagnostics endpoint round-trip can be exercised. -->
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Diagnostics Sample</title>
</head>
<body>
<div id="dup">first</div>
<div id="dup">second</div>
</body>
</html>
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Serena HTML Test</title>
<link rel="stylesheet" href="styles/main.css">
</head>
<body>
<header id="page-header">
<h1 id="site-title">Serena HTML Test Repo</h1>
<nav id="main-nav">
<a href="about.html">About</a>
<a href="#section-features">Features</a>
</nav>
</header>
<main id="page-main">
<section id="section-intro">
<h2>Introduction</h2>
<p>A minimal HTML test fixture used by the Serena language-server tests.</p>
</section>
<section id="section-features">
<h2>Features</h2>
<ul id="feature-list">
<li>Symbol detection</li>
<li>Element navigation</li>
<li>Cross-file links</li>
</ul>
</section>
</main>
<footer id="page-footer">
<p>&copy; Serena</p>
</footer>
<script src="scripts/app.js"></script>
</body>
</html>
@@ -0,0 +1,17 @@
@use "variables" as vars;
// Common card-style container used by buttons, panels, etc.
@mixin card-surface($bg: vars.$color-bg) {
background-color: $bg;
border-radius: 4px;
padding: vars.$space-md;
}
@mixin focus-ring($color: vars.$color-primary) {
outline: 2px solid $color;
outline-offset: 2px;
}
@function rem($px) {
@return ($px / 16) * 1rem;
}
@@ -0,0 +1,10 @@
// Color tokens used across the design system.
$color-primary: #2563eb;
$color-secondary: #f97316;
$color-text: #1a1a1a;
$color-bg: #ffffff;
// Spacing scale.
$space-sm: 0.5rem;
$space-md: 1rem;
$space-lg: 2rem;
@@ -0,0 +1,22 @@
@use "variables" as vars;
@use "mixins" as mix;
.button {
@include mix.card-surface();
color: vars.$color-text;
cursor: pointer;
&:focus-visible {
@include mix.focus-ring();
}
}
.button-primary {
@include mix.card-surface(vars.$color-primary);
color: white;
}
.button-secondary {
@include mix.card-surface(vars.$color-secondary);
color: white;
}
@@ -0,0 +1,13 @@
/*
* Plain-CSS diagnostics sample. The vscode-css-languageservice engine inside
* some-sass reports parser errors here even with lint diagnostics off — the
* unterminated rule body and stray brace produce syntax-level diagnostics.
*/
.broken {
color: red;
background:
}
.also-broken } {
margin: 0;
}
@@ -0,0 +1,41 @@
@import "reset.css";
@import "theme.css";
body {
font-family: var(--font-body);
color: var(--color-text);
background-color: var(--color-bg);
margin: 0;
}
#page-header {
padding: 1rem;
background-color: var(--color-primary);
}
#site-title {
margin: 0;
font-size: 1.5rem;
}
.button {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.button-primary {
background-color: var(--color-primary);
color: white;
}
.button-secondary {
background-color: transparent;
border: 1px solid var(--color-primary);
}
@media (max-width: 600px) {
#site-title {
font-size: 1.2rem;
}
}
@@ -0,0 +1,18 @@
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
}
a {
color: inherit;
text-decoration: none;
}
ul {
list-style: none;
padding: 0;
}
@@ -0,0 +1,15 @@
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-primary: #2563eb;
--color-secondary: #f97316;
--font-body: "Inter", system-ui, sans-serif;
--font-heading: "Inter", system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-text: #f1f5f9;
}
}
@@ -0,0 +1,5 @@
// Some Sass flags references to undefined variables and missing semicolons.
.broken {
color: $undefined-variable
background: $also-not-defined;
}
@@ -0,0 +1,16 @@
@use "variables" as vars;
@use "mixins" as mix;
@forward "buttons";
body {
background-color: vars.$color-bg;
color: vars.$color-text;
margin: 0;
padding: vars.$space-lg;
font-size: mix.rem(16);
}
.panel {
@include mix.card-surface();
margin-bottom: vars.$space-md;
}
+88
View File
@@ -0,0 +1,88 @@
"""
Pytest fixtures for Angular language server tests.
This conftest is NOT installing the language server itself — that is fully handled
by ``RuntimeDependencyCollection`` inside ``AngularLanguageServer`` and lands in
Serena's managed ``ls_resources_dir`` like every other LS download.
What we install here is the *test fixture project's* npm dependencies (notably
``@angular/core``) into the fixture's own ``node_modules``. ngserver requires
``@angular/core`` to be resolvable from the workspace root or it silently treats
every file as "not in an Angular project", at which point all template-aware
features (definition / hover / references on .html, cross-file template→component
navigation) return empty results — tests would pass vacuously while exercising
nothing. Vendoring ``node_modules`` in-repo is impractical (~500MB), so we run
``npm install`` once per checkout, cached across sessions, and serialised across
xdist workers via ``filelock``.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from filelock import FileLock
log = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parents[2] / "resources" / "repos" / "angular" / "test_repo"
NODE_MODULES = REPO_ROOT / "node_modules"
ANGULAR_CORE_MARKER = NODE_MODULES / "@angular" / "core" / "package.json"
# Lock file lives inside the repo (covered by its .gitignore) so xdist workers
# can serialise on the install. Putting it elsewhere risks placing it on a
# different filesystem from the install dir, which makes flock semantics fuzzy.
INSTALL_LOCK = REPO_ROOT / ".angular-install.lock"
@pytest.fixture(scope="session", autouse=True)
def _install_angular_test_repo_node_modules() -> None:
"""
Populate the *test fixture project's* ``node_modules`` (NOT the language server's
install; see module docstring for the distinction).
Cached across sessions: once ``node_modules/@angular/core/package.json`` exists,
this is a no-op. Skipped (with the test session marked) if npm is unavailable.
Under pytest-xdist multiple workers race on the install. ``npm install`` does
not internally serialize concurrent runs against the same install dir, so we
take an exclusive ``filelock`` around the whole install/check sequence — the
second worker into the lock will then short-circuit on the marker check.
"""
if ANGULAR_CORE_MARKER.exists():
log.info("Angular test repo node_modules already populated; skipping npm install")
return
if shutil.which("npm") is None:
pytest.skip("npm is not available; cannot install Angular test repo dependencies")
with FileLock(str(INSTALL_LOCK)):
# Re-check inside the lock: a sibling worker may have just installed.
if ANGULAR_CORE_MARKER.exists():
log.info("Angular test repo node_modules populated by another worker; skipping npm install")
return
log.warning(
"Installing npm dependencies into the Angular test repo at %s. This is a one-time cost per checkout and may take ~30s.",
REPO_ROOT,
)
proc = subprocess.run(
["npm", "install", "--no-audit", "--no-fund", "--loglevel=warn"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
if proc.returncode != 0:
log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr)
pytest.skip(f"npm install failed in {REPO_ROOT} (rc={proc.returncode}); see logs for details")
if not ANGULAR_CORE_MARKER.exists():
pytest.skip(f"npm install completed but {ANGULAR_CORE_MARKER} is missing; cannot run Angular tests")
log.info("Angular test repo node_modules installed successfully")
+344
View File
@@ -0,0 +1,344 @@
"""
Basic integration tests for the Angular language server.
The Angular LS understands Angular template syntax (*ngIf, [prop], (event),
{{ interpolation }}, @if/@for blocks) and provides type-aware navigation
between templates and component classes.
Workspace: a minimal standalone-component Angular app with a service that the
component injects, plus a template (.html) that interpolates component methods.
"""
import os
import re
from pathlib import Path
import pytest
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import read_repo_file, request_all_symbols
@pytest.mark.angular
class TestAngularLanguageServerBasics:
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.ANGULAR], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_symbols(self, language_server: SolidLanguageServer) -> None:
"""The Angular LS exposes the component class methods/fields via tsserver."""
all_symbols, _ = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("AppComponent", "title", "userName", "items", "greeting", "setName"):
assert expected in names, f"Expected '{expected}' in component symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_service_class_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("src/app/greeting.service.ts").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("GreetingService", "greet", "defaultName"):
assert expected in names, f"Expected '{expected}' in service symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None:
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
# The HTML companion is unconditionally started, so .html documentSymbol
# contributes structural element entries to the workspace tree alongside
# the two TS files.
# relativePath uses OS-native separators (Path.relative_to), so build expectations the same way.
for f in (
os.path.join("src", "app", "app.component.ts"),
os.path.join("src", "app", "greeting.service.ts"),
os.path.join("src", "app", "app.component.html"),
):
assert f in relative_paths, f"Expected {f} to appear in symbol tree, got {relative_paths}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_definition_to_component_method(self, language_server: SolidLanguageServer) -> None:
"""Resolve `greeting()` interpolation in the template to its component method.
Cursor lands inside the ``greeting`` identifier on the ``{{ greeting() }}``
line of ``app.component.html``.
"""
path = "src/app/app.component.html"
needle = "greeting()"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# +1 puts the cursor inside the identifier rather than on its leading boundary.
definitions = language_server.request_definition(path, line, col + 1)
assert definitions, f"Expected non-empty cross-file definition for template->component method, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_plain_html_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""DocumentSymbol on plain index.html should come back from the HTML companion.
ngserver returns -32601 for documentSymbol on every .html file (plain or
Angular template). The AngularLanguageServer routes .html documentSymbol
to a tertiary vscode-html-language-server companion so users get a
structural element outline instead of an empty list.
"""
all_symbols, _ = language_server.request_document_symbols("src/index.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
# index.html contains <html>, <head>, <meta>, <title>, <body>, <app-root>
for expected in ("html", "head", "body", "app-root"):
assert expected in names, f"Expected '{expected}' in plain-HTML symbol list, got: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_html_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""DocumentSymbol on an Angular template must return the HTML element tree.
Angular template syntax (``@if``/``@for``/``{{ }}``) is ignored by the
vscode-html-language-server parser but the surrounding element structure
(``<section>``, ``<h1>``, ``<p>``, ``<input>``, ``<ul>``, ``<app-item-card>``)
is still reported, which is the intended outline.
"""
all_symbols, _ = language_server.request_document_symbols("src/app/app.component.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("section", "h1", "p", "input", "ul", "app-item-card"):
assert expected in names, f"Expected '{expected}' in template HTML symbol list, got: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_ts_method_references_include_template_usage(self, language_server: SolidLanguageServer) -> None:
"""References on a .ts component method must include its template callers.
``setName`` is defined in app.component.ts and bound in the template via
``(input)="setName(...)"``. On real Angular projects the typescript-language-server
alone misses the template callers, so the Angular LS routes .ts references
through ngserver which aggregates both .ts and .html usages.
"""
src_path = "src/app/app.component.ts"
# Probe at the declaration site of setName, not a usage; the regex anchors on the
# parameter list signature so we always land on the method header even if calls move.
coords = find_text_coordinates(read_repo_file(language_server, src_path), r"(setName)\(name:")
assert coords is not None, "Could not locate setName declaration in app.component.ts"
# +1 puts the cursor inside the identifier rather than on its leading boundary.
refs = language_server.request_references(src_path, coords.line, coords.col + 1)
ref_paths = {r.get("relativePath", "") for r in refs}
assert any(p.endswith("app.component.html") for p in ref_paths), (
f"Expected references for setName to include its template callsite in app.component.html, got: {ref_paths}"
)
@pytest.mark.angular
class TestAngularHover:
"""Hover routing — .ts goes to the companion tsserver, .html goes to ngserver."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_hover_on_ts_method(self, language_server: SolidLanguageServer) -> None:
"""Hover on a .ts method declaration is routed through the companion TS server
and must yield a non-empty MarkupContent describing the method signature.
"""
path = "src/app/app.component.ts"
needle = "setName("
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past the leading 's') so the LSP
# treats the position as the symbol rather than a token boundary.
hover = language_server.request_hover(path, line, col + 1)
assert hover is not None, f"Expected hover info for setName in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "setName" in text, f"Expected setName in hover text, got: {text}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_hover_on_template_method_call(self, language_server: SolidLanguageServer) -> None:
"""Hover on a method call inside an Angular template ({{ greeting() }}) goes
through ngserver and must yield Angular-aware type info.
"""
path = "src/app/app.component.html"
needle = "greeting()"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past the leading 'g').
hover = language_server.request_hover(path, line, col + 1)
assert hover is not None, f"Expected hover info for greeting() in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
@pytest.mark.angular
class TestAngularDefinitionRouting:
"""Cross-file definition for the binding flavours not covered by basic tests."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_property_binding(self, language_server: SolidLanguageServer) -> None:
"""Property binding ``[value]="userName()"`` must resolve to the component's
``userName`` signal field declaration in app.component.ts.
"""
path = "src/app/app.component.html"
needle = '[value]="userName()"'
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor on the 'u' of userName inside the binding expression
col = col + len('[value]="')
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected non-empty definition for userName binding, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_event_binding(self, language_server: SolidLanguageServer) -> None:
"""Event binding ``(input)="setName(...)"`` must resolve to the component's
``setName`` method declaration in app.component.ts.
"""
path = "src/app/app.component.html"
needle = '(input)="setName('
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + len('(input)="') # cursor on 's' of setName
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected non-empty definition for setName binding, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_service_import_in_component(self, language_server: SolidLanguageServer) -> None:
"""The ``GreetingService`` symbol used in the constructor parameter list
of AppComponent must resolve to greeting.service.ts via the companion TS server.
"""
path = "src/app/app.component.ts"
# Probe at the constructor signature, not the import line, to make sure
# we exercise the type-resolution path rather than the module-resolution path.
needle = "private readonly greetings: GreetingService"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + len("private readonly greetings: ") # cursor on 'G' of GreetingService
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected definition for GreetingService, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("greeting.service.ts") for uri in target_uris), (
f"Expected definition to resolve into greeting.service.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_child_component_selector(self, language_server: SolidLanguageServer) -> None:
"""The ``<app-item-card>`` element in the parent template must resolve to the
ItemCardComponent class declaration in item-card.component.ts.
This is an Angular-specific feature: the @angular/language-service tsserver
plugin makes the selector string a navigable type-reference back to its
component class.
"""
path = "src/app/app.component.html"
needle = "app-item-card"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + 1 # cursor inside 'app-item-card'
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected definition for <app-item-card>, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("item-card.component.ts") for uri in target_uris), (
f"Expected definition to resolve into item-card.component.ts, got URIs: {target_uris}"
)
@pytest.mark.angular
class TestAngularRename:
"""Rename routing returns a WorkspaceEdit without applying it (safe for fixtures)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_rename_method_returns_edits_for_ts_and_template(self, language_server: SolidLanguageServer) -> None:
"""Renaming the ``setName`` method from its .ts declaration must return a
WorkspaceEdit that touches both app.component.ts (declaration + any TS calls)
and app.component.html (the ``(input)="setName(...)"`` binding).
"""
path = "src/app/app.component.ts"
# Anchor on the declaration site (signature with the parameter list), not a usage.
coords = find_text_coordinates(read_repo_file(language_server, path), r"(setName)\(name:")
assert coords is not None, "Could not locate setName declaration in app.component.ts"
# +1 puts the cursor inside the identifier rather than on its leading boundary.
edit = language_server.request_rename_symbol_edit(path, coords.line, coords.col + 1, "updateName")
assert edit is not None, "Expected WorkspaceEdit, got None"
changes = edit.get("changes") or {}
# Some servers return ``documentChanges`` instead of (or in addition to) ``changes``.
document_changes = edit.get("documentChanges") or []
all_uris: set[str] = set(changes.keys())
for dc in document_changes:
uri = (dc.get("textDocument") or {}).get("uri") or dc.get("uri")
if uri:
all_uris.add(uri)
assert any(uri.endswith("app.component.ts") for uri in all_uris), f"Expected rename edits in app.component.ts, got URIs: {all_uris}"
assert any(uri.endswith("app.component.html") for uri in all_uris), (
f"Expected rename edits in app.component.html (template binding), got URIs: {all_uris}"
)
@pytest.mark.angular
class TestAngularSymbolStructure:
"""Hierarchical symbol structure — class symbols must contain method/field children."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_has_methods_and_fields_as_children(self, language_server: SolidLanguageServer) -> None:
"""``request_document_symbols`` should return AppComponent as a class symbol
whose children include its methods (``greeting``, ``setName``) and signal
fields (``title``, ``userName``, ``items``). Flat-name checks pass even when
the hierarchy is broken; this test asserts the parent-child relationship.
"""
all_symbols, root_symbols = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots()
component = next((s for s in all_symbols if s.get("name") == "AppComponent"), None)
assert component is not None, "AppComponent class symbol not found"
children = component.get("children") or []
child_names = {c.get("name") for c in children}
for expected in ("greeting", "setName", "title", "userName", "items"):
assert expected in child_names, f"Expected '{expected}' as child of AppComponent, got: {child_names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_pipe_class_in_symbol_tree(self, language_server: SolidLanguageServer) -> None:
"""A custom pipe (``ExclaimPipe``) declared in exclaim.pipe.ts must appear in
the workspace symbol tree alongside the components and the service.
"""
all_symbols = request_all_symbols(language_server)
names = {s.get("name") for s in all_symbols}
for expected in ("ExclaimPipe", "ItemCardComponent", "AppComponent", "GreetingService"):
assert expected in names, f"Expected '{expected}' in full symbol tree, got: {sorted(names)}"
@pytest.mark.angular
class TestAngularImplementations:
"""``textDocument/implementation`` is routed through tsserver via the Angular plugin."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_find_implementations_of_interface_method(self, language_server: SolidLanguageServer) -> None:
"""``Greeter.greet`` is implemented by ``GreetingService``; ``request_implementation``
invoked at the interface declaration must point at the service.
Routed through the Angular LS' companion typescript-language-server (with the
@angular/language-service plugin loaded as a tsserver plugin) — bare ngserver
does not own ``textDocument/implementation``.
"""
path = "src/app/greeter.interface.ts"
needle = "greet("
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past 'g').
implementations = language_server.request_implementation(path, line, col + 1)
assert implementations, f"Expected at least one implementation of Greeter.greet, got {implementations}"
target_uris = [impl["uri"] for impl in implementations]
assert any(uri.endswith("greeting.service.ts") for uri in target_uris), (
f"Expected implementation to resolve into greeting.service.ts, got URIs: {target_uris}"
)
@@ -0,0 +1,44 @@
"""
Diagnostics tests for the Angular language server.
Two paths are exercised:
* .ts component — tsserver (via the @angular/language-service plugin) reports
the type-mismatch on a class field initializer.
* .html template — ngserver reports the unresolved identifier in a template
interpolation, but only because the template is attached to a @Component via
``templateUrl``. Bare .html files are not type-checked by ngserver.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.angular
class TestAngularDiagnostics:
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""The component's ``count: number = 'not-a-number'`` must be flagged by tsserver."""
assert_file_diagnostics(
language_server,
"src/app/diagnostics_sample.ts",
(),
min_count=1,
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""The template's ``{{ undefinedSignal() }}`` must be flagged by ngserver.
Routed through the Angular template compiler, which only checks templates
attached to a @Component — see the companion ``diagnostics_sample.ts`` that
wires this file via ``templateUrl``.
"""
assert_file_diagnostics(
language_server,
"src/app/diagnostics_sample.html",
(),
min_count=1,
)
@@ -0,0 +1,369 @@
"""
Error / edge-case tests for the Angular language server.
The Angular LS routes by file extension across three child processes:
* ``ngserver`` for ``.html`` definition / references / hover
* companion ``typescript-language-server`` (with @angular/language-service
loaded as a tsserver plugin) for ``.ts`` operations
* companion ``vscode-html-language-server`` for ``.html`` documentSymbol
The behaviour locked in below is **observed** behaviour on Linux with the
versions pinned in ``angular_language_server.py`` (Angular LS 21.2.10,
typescript-language-server 5.1.3, TypeScript 5.9.3). It is platform-aware where
upstream LSPs are known to differ (Windows TS server tends to swallow malformed
positions instead of raising).
"""
import os
import sys
import time
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_exceptions import SolidLSPException
from test.conftest import _create_ls
pytestmark = pytest.mark.angular
IS_WINDOWS = sys.platform == "win32"
TS_FILE = os.path.join("src", "app", "app.component.ts")
SERVICE_FILE = os.path.join("src", "app", "greeting.service.ts")
TEMPLATE_FILE = os.path.join("src", "app", "app.component.html")
class TestAngularInvalidPositionsOnTs:
"""Negative / out-of-bounds line and column on .ts files (TS-companion route)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_negative_line_number_containing_symbol(self, language_server: SolidLanguageServer) -> None:
"""``request_containing_symbol`` short-circuits before reaching the LS:
a negative line returns None.
"""
result = language_server.request_containing_symbol(TS_FILE, -1, 0)
assert result is None, f"Expected None for negative line, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_negative_character_number_containing_symbol(self, language_server: SolidLanguageServer) -> None:
result = language_server.request_containing_symbol(TS_FILE, 5, -1)
assert result is None, f"Expected None for negative character, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None:
"""The wrapper code raises ``IndexError`` before reaching the LS."""
with pytest.raises(IndexError) as exc_info:
language_server.request_containing_symbol(TS_FILE, 99999, 0)
assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_character_beyond_line_length_returns_enclosing_class(self, language_server: SolidLanguageServer) -> None:
"""Character far beyond end-of-line is clamped by the LSP. Line 5 of
app.component.ts is inside ``export class AppComponent { ... }``, so the
TS companion (with the Angular plugin) returns the AppComponent class
as the containing symbol — not None — which is the documented LSP
behaviour.
"""
result = language_server.request_containing_symbol(TS_FILE, 5, 99999)
assert isinstance(result, dict), f"Expected dict (containing class), got: {result!r}"
assert result.get("name") == "AppComponent", f"Expected containing symbol 'AppComponent', got: {result.get('name')!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_references_at_negative_line_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""The Angular LS's TS companion returns ``[]`` for negative-line
``request_references`` — it does **not** raise. (Plain Vue tests
observe a ``Bad line number`` raise on Linux/macOS; the Angular
plugin layered on tsserver suppresses this.)
"""
result = language_server.request_references(TS_FILE, -1, 0)
assert result == [], f"Expected [], got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_at_negative_position_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""Same as references: TS companion returns ``[]``, does not raise."""
result = language_server.request_definition(TS_FILE, -1, 0)
assert result == [], f"Expected [], got: {result!r}"
@pytest.mark.skipif(
IS_WINDOWS,
reason="Windows ngserver swallows malformed positions instead of raising — same divergence as the TS companion (see test_referencing_symbols_at_invalid_position_raises).",
)
class TestAngularInvalidPositionsOnTemplate:
"""Negative / out-of-bounds positions on .html files (ngserver route)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_negative_line_definition_raises(self, language_server: SolidLanguageServer) -> None:
"""Ngserver does **not** swallow malformed positions: it surfaces a
``Debug Failure. False expression.`` error from its underlying
compiler. Lock that behaviour to catch any future change.
"""
with pytest.raises(SolidLSPException) as exc_info:
language_server.request_definition(TEMPLATE_FILE, -1, 0)
assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), (
f"Unexpected exception message: {exc_info.value}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_character_beyond_line_definition_raises(self, language_server: SolidLanguageServer) -> None:
"""Same path as negative line — ngserver raises Debug Failure."""
with pytest.raises(SolidLSPException) as exc_info:
language_server.request_definition(TEMPLATE_FILE, 0, 99999)
assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), (
f"Unexpected exception message: {exc_info.value}"
)
class TestAngularNonExistentFiles:
"""Requests against files that don't exist must raise FileNotFoundError consistently."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_nonexistent_ts_file_raises(self, language_server: SolidLanguageServer) -> None:
nonexistent = os.path.join("src", "app", "does-not-exist.component.ts")
with pytest.raises(FileNotFoundError):
language_server.request_references(nonexistent, 0, 0)
with pytest.raises(FileNotFoundError):
language_server.request_definition(nonexistent, 0, 0)
with pytest.raises(FileNotFoundError):
language_server.request_document_symbols(nonexistent)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_nonexistent_html_file_raises(self, language_server: SolidLanguageServer) -> None:
nonexistent = os.path.join("src", "app", "does-not-exist.component.html")
with pytest.raises(FileNotFoundError):
language_server.request_definition(nonexistent, 0, 0)
with pytest.raises(FileNotFoundError):
language_server.request_document_symbols(nonexistent)
class TestAngularUndefinedSymbols:
"""Symbols that have no callers / definitions / referencing positions."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_at_keyword_position_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""Cursor on the ``import`` keyword (line 0, col 0 of app.component.ts)
has no definition target — the TS companion returns ``[]``.
"""
result = language_server.request_definition(TS_FILE, 0, 0)
assert result == [], f"Expected [] for keyword position, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_references_for_local_const_have_few_callers(self, language_server: SolidLanguageServer) -> None:
"""A locally-scoped private field has at most its declaration plus its
in-method use as references. ``defaultName`` on GreetingService is
used only inside ``greet()``.
"""
all_symbols, _ = language_server.request_document_symbols(SERVICE_FILE).get_all_symbols_and_roots()
sym = next((s for s in all_symbols if s.get("name") == "defaultName"), None)
assert sym is not None, "defaultName symbol missing in fixture"
sel = sym["selectionRange"]["start"]
refs = language_server.request_references(SERVICE_FILE, sel["line"], sel["character"])
assert isinstance(refs, list), f"Expected list, got {type(refs)}"
# 1 declaration + 1 internal read = 2 expected; allow 1-3 to absorb
# whether the LSP includes the declaration.
assert 1 <= len(refs) <= 3, f"defaultName should have 1-3 references, got {len(refs)}: {refs}"
class TestAngularEdgeCasePositions:
"""Position (0,0), whitespace lines, and other boundary conditions."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_containing_symbol_at_file_start_is_none(self, language_server: SolidLanguageServer) -> None:
"""Line 0 of app.component.ts is an ``import`` statement, outside any
class or function — the TS companion returns ``None`` for containing
symbol.
"""
result = language_server.request_containing_symbol(TS_FILE, 0, 0)
assert result is None, f"Expected None at (0,0), got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_references_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""Position (0, 0) is on the ``import`` keyword — no references."""
result = language_server.request_references(TS_FILE, 0, 0)
assert result == [], f"Expected [] at (0,0), got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""Position (0, 0) is on the ``import`` keyword — no definition."""
result = language_server.request_definition(TS_FILE, 0, 0)
assert result == [], f"Expected [] at (0,0), got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_position_no_containing_symbol(self, language_server: SolidLanguageServer) -> None:
"""An Angular template has no class/function containers; the HTML
companion's documentSymbol provides element symbols only.
"""
# (1, 4) lands inside <h1>{{ title() | exclaim }}</h1> on line 1.
result = language_server.request_containing_symbol(TEMPLATE_FILE, 1, 4)
assert result is None, f"Expected None inside template, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_containing_symbol_inside_class_body_returns_class(self, language_server: SolidLanguageServer) -> None:
"""Lines 5 and 10 of app.component.ts are inside the AppComponent class
body. The TS companion correctly reports AppComponent as the
containing symbol.
"""
for line in (5, 10):
result = language_server.request_containing_symbol(TS_FILE, line, 0)
assert isinstance(result, dict), f"Line {line}: expected dict, got {result!r}"
assert result.get("name") == "AppComponent", f"Line {line}: expected 'AppComponent', got {result.get('name')!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_containing_symbol_outside_class_body_is_none(self, language_server: SolidLanguageServer) -> None:
"""Line 0 (import) and line 15 (blank/whitespace inside file but
outside any symbol's range, depending on file structure) report no
containing symbol.
"""
for line in (0,):
result = language_server.request_containing_symbol(TS_FILE, line, 0)
assert result is None, f"Line {line}: expected None, got {result!r}"
class TestAngularReferenceEdgeCases:
"""Edge cases for the SolidLanguageServer reference helpers (built on top of LSP)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_referencing_symbols_at_invalid_position_raises(self, language_server: SolidLanguageServer) -> None:
"""Unlike ``request_references`` (which returns ``[]``),
``request_referencing_symbols`` validates more strictly and surfaces
the underlying TS server error as a ``SolidLSPException``.
"""
if IS_WINDOWS:
# Windows TS server is known to swallow these — keep platform-specific
# contract minimal here since we can't run Windows in dev.
result = list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False))
assert result == [], f"Expected [] on Windows, got: {result!r}"
return
with pytest.raises(SolidLSPException) as exc_info:
list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False))
assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value), (
f"Unexpected exception message: {exc_info.value}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_defining_symbol_at_invalid_position_returns_none(self, language_server: SolidLanguageServer) -> None:
"""``request_defining_symbol`` short-circuits when no definition is
found and returns None — no exception, even for negative positions.
"""
result = language_server.request_defining_symbol(TS_FILE, -1, -1)
assert result is None, f"Expected None, got: {result!r}"
class TestAngularCoreProbe:
"""Unit tests for the monorepo-aware ``_find_angular_core_install`` walk.
These don't spin up the Angular LS — they exercise the pure-Python
filesystem walker against synthetic node_modules layouts.
"""
@staticmethod
def _walk(repo_path: str) -> str | None:
"""Invoke the bound method against a minimal stub carrying only
``repository_root_path``.
"""
from solidlsp.language_servers.angular_language_server import AngularLanguageServer
stub = type("Stub", (), {"repository_root_path": repo_path})()
return AngularLanguageServer._find_angular_core_install(stub) # type: ignore[arg-type]
def test_finds_core_at_project_root(self, tmp_path) -> None:
core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
core_pkg.parent.mkdir(parents=True)
core_pkg.write_text("{}")
assert self._walk(str(tmp_path)) == str(core_pkg)
def test_finds_hoisted_core_in_workspace_parent(self, tmp_path) -> None:
"""Nx / yarn-workspaces layout: ``node_modules`` is hoisted to the
workspace root and the activated sub-package has none of its own.
"""
sub_pkg = tmp_path / "packages" / "app"
sub_pkg.mkdir(parents=True)
core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
core_pkg.parent.mkdir(parents=True)
core_pkg.write_text("{}")
# Workspace root marker — the walker stops here.
(tmp_path / "package.json").write_text('{"workspaces": ["packages/*"]}')
assert self._walk(str(sub_pkg)) == str(core_pkg)
def test_returns_none_when_not_installed(self, tmp_path) -> None:
"""No node_modules anywhere — walker exhausts and returns None."""
sub_pkg = tmp_path / "packages" / "app"
sub_pkg.mkdir(parents=True)
assert self._walk(str(sub_pkg)) is None
def test_workspace_root_stops_walk(self, tmp_path) -> None:
"""If a ``package.json`` declares ``workspaces`` we should not walk past
it — the workspace root is the canonical install location even when no
``@angular/core`` is present there.
"""
# Plant a misleading sibling install ABOVE the workspace root that the
# walker must NOT find.
sibling_core = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
sibling_core.parent.mkdir(parents=True)
sibling_core.write_text("{}")
workspace = tmp_path / "ws"
workspace.mkdir()
(workspace / "package.json").write_text('{"workspaces": ["packages/*"]}')
sub_pkg = workspace / "packages" / "app"
sub_pkg.mkdir(parents=True)
assert self._walk(str(sub_pkg)) is None
class TestAngularStartupCleanup:
"""Regression: companion processes (TS + HTML) must not leak when ngserver
fails partway through ``_start_server``.
Until the cleanup wrapper landed, an exception during ngserver init left
the two companion Node processes orphaned because the parent constructor
never returned a handle on which the caller could invoke ``stop()``.
"""
def test_companions_cleaned_up_on_initialize_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
if IS_WINDOWS:
pytest.skip("psutil child enumeration is flaky under Windows CI")
import psutil
ls = _create_ls(Language.ANGULAR)
my_proc = psutil.Process(os.getpid())
children_before = {p.pid for p in my_proc.children(recursive=True)}
def boom(*_args: object, **_kwargs: object) -> dict:
raise RuntimeError("simulated ngserver init failure")
# _get_initialize_params runs after both companions and ngserver have
# been spawned — exactly the failure window the cleanup wrapper
# protects against.
monkeypatch.setattr(ls, "_get_initialize_params", boom)
try:
with pytest.raises(RuntimeError, match="simulated ngserver init"):
ls.start()
assert ls._ts_server is None, "TS companion was not cleared after startup failure"
assert ls._html_server is None, "HTML companion was not cleared after startup failure"
# Allow a brief grace period for the OS to reap the spawned Node
# processes (and grandchildren — typescript-language-server forks
# tsserver).
deadline = time.monotonic() + 5.0
new_children: set[int] = set()
while time.monotonic() < deadline:
children_after = {p.pid for p in my_proc.children(recursive=True)}
new_children = children_after - children_before
if not new_children:
break
time.sleep(0.2)
assert not new_children, f"Companion processes leaked after startup failure: {sorted(new_children)}"
finally:
# Best-effort: if anything is still around (e.g. ngserver itself,
# which lives outside the cleanup contract), shut it down so the
# test session doesn't leave orphans.
try:
ls.stop(shutdown_timeout=2.0)
except Exception:
pass
+12
View File
@@ -1,3 +1,5 @@
from pathlib import Path
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
@@ -5,6 +7,16 @@ from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY]
def read_repo_file(language_server: SolidLanguageServer, relative_path: str) -> str:
"""Read the text content of ``relative_path`` resolved against the LS's repository root.
Convenience for test code that needs to feed file content to
:func:`serena.util.text_utils.find_text_coordinates`.
"""
abs_path = Path(language_server.language_server.repository_root_path) / relative_path
return abs_path.read_text()
def is_diagnostics_test_file(relative_path: str) -> bool:
normalized_path = relative_path.replace("\\", "/")
filename = normalized_path.rsplit("/", 1)[-1].lower()
View File
Whitespace-only changes.
+54
View File
@@ -0,0 +1,54 @@
"""
Basic integration tests for the HTML language server.
The HTML LSP (vscode-html-language-server) provides in-file document symbols
based on the element tree. Cross-file navigation (definition / references) is
not meaningful for HTML and is therefore not tested here.
"""
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import request_all_symbols
@pytest.mark.html
class TestHtmlLanguageServerBasics:
"""Smoke + symbol tests for the HTML language server."""
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.HTML], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
def test_index_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""The HTML LSP exposes elements/IDs as document symbols."""
all_symbols, _ = language_server.request_document_symbols("index.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
# vscode-html-language-server reports elements by tag name and emits an entry
# per element with an id attribute. Names contain the id like "header#page-header"
# or just the tag like "head" for elements without an id; we check both forms.
joined = " | ".join(names)
for expected_id in ("page-header", "site-title", "main-nav", "section-features", "feature-list", "page-footer"):
assert expected_id in joined, f"Expected id '{expected_id}' to appear in HTML symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
def test_about_document_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("about.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
joined = " | ".join(names)
for expected_id in ("page-header", "about-title", "main-nav", "about-article"):
assert expected_id in joined, f"Expected id '{expected_id}' to appear in HTML symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
def test_full_symbol_tree_includes_both_files(self, language_server: SolidLanguageServer) -> None:
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
assert "index.html" in relative_paths
assert "about.html" in relative_paths
@@ -0,0 +1,27 @@
"""
Diagnostics smoke test for the HTML language server.
``vscode-html-language-server`` does not validate HTML structure itself — upstream
``vscode-html-languageservice`` only forwards validation to embedded sub-services
(CSS/JS in ``<style>`` / ``<script>`` blocks), and those aren't wired up in
standalone mode. So a malformed-HTML fixture would yield zero diagnostics regardless.
Asserting "at least one diagnostic" would be a false positive waiting to happen.
Instead, this test verifies the diagnostics endpoint is reachable end-to-end and
the framework's pull→publish fallback handles the empty response correctly — i.e.
it returns a list, not an error. That alone catches LS-crash regressions on the
diagnostic request, which is the practical purpose for this LS.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
@pytest.mark.html
class TestHtmlDiagnostics:
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
def test_diagnostics_endpoint_returns_list(self, language_server: SolidLanguageServer) -> None:
diagnostics = language_server.request_text_document_diagnostics("diagnostics_sample.html", min_severity=1)
assert isinstance(diagnostics, list), diagnostics
View File
Whitespace-only changes.
+398
View File
@@ -0,0 +1,398 @@
"""
Basic integration tests for the SCSS language server (Some Sass).
Some Sass provides full @use/@forward workspace navigation, so this suite
exercises both in-file document symbols and cross-file go-to-definition for
variables and mixins.
"""
import os
import re
from pathlib import Path
import pytest
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes
from test.solidlsp.conftest import read_repo_file, request_all_symbols
@pytest.mark.scss
class TestScssLanguageServerBasics:
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.SCSS], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_variables_document_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("_variables.scss").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for var in ("$color-primary", "$color-secondary", "$color-text", "$space-md", "$space-lg"):
assert var in names, f"Expected variable {var} to appear in SCSS symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_mixins_document_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("_mixins.scss").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
# Some Sass surfaces @mixin and @function entries; names may be bare ("card-surface")
# or include the @-keyword. Check substring inclusion for robustness.
joined = " | ".join(names)
for expected in ("card-surface", "focus-ring", "rem"):
assert expected in joined, f"Expected '{expected}' to appear in SCSS mixin symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_buttons_document_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("buttons.scss").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
joined = " | ".join(names)
for selector in (".button", ".button-primary", ".button-secondary"):
assert selector in joined, f"Expected selector '{selector}' to appear in SCSS symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_cross_file_definition_variable(self, language_server: SolidLanguageServer) -> None:
"""`vars.$color-text` in buttons.scss must resolve into _variables.scss."""
path = "buttons.scss"
needle = "$color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the variable identifier.
definitions = language_server.request_definition(path, line, col + 2)
assert definitions, f"Expected non-empty cross-file definition list for vars.$color-text, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("_variables.scss") for uri in target_uris), (
f"Expected definition to resolve into _variables.scss, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_cross_file_definition_mixin(self, language_server: SolidLanguageServer) -> None:
"""`mix.card-surface` in buttons.scss must resolve into _mixins.scss."""
path = "buttons.scss"
needle = "card-surface"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
definitions = language_server.request_definition(path, line, col + 2)
assert definitions, f"Expected non-empty cross-file definition list for mix.card-surface, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("_mixins.scss") for uri in target_uris), (
f"Expected definition to resolve into _mixins.scss, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_cross_file_definition_function(self, language_server: SolidLanguageServer) -> None:
"""`mix.rem(16)` in main.scss must resolve into _mixins.scss (an @function)."""
path = "main.scss"
needle = "mix.rem"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the function identifier (skip the `mix.` prefix).
definitions = language_server.request_definition(path, line, col + 5)
assert definitions, f"Expected non-empty cross-file definition list for mix.rem, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("_mixins.scss") for uri in target_uris), (
f"Expected definition to resolve into _mixins.scss, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None:
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
for f in ("_variables.scss", "_mixins.scss", "buttons.scss", "main.scss"):
assert f in relative_paths, f"Expected {f} to appear in symbol tree"
@pytest.mark.scss
class TestScssReferences:
"""Find-references for symbols re-exported via @use across files."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_mixin_references_span_files(self, language_server: SolidLanguageServer) -> None:
"""References for ``card-surface`` must include both ``buttons.scss``
(`.button`, `.button-primary`, `.button-secondary` all `@include` it) and
``main.scss`` (`.panel` `@include`s it).
Some Sass returns no references when the request originates on the
``@mixin`` declaration itself, so we probe from a usage site in
``buttons.scss`` — that's also closer to how an editor user invokes
find-references in practice.
"""
path = "buttons.scss"
needle = "card-surface"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
refs = language_server.request_references(path, line, col + 2)
ref_paths = {r.get("relativePath", "") for r in refs}
assert any(p.endswith("buttons.scss") for p in ref_paths), (
f"Expected card-surface references to include buttons.scss, got: {ref_paths}"
)
assert any(p.endswith("main.scss") for p in ref_paths), f"Expected card-surface references to include main.scss, got: {ref_paths}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_variable_references_span_files(self, language_server: SolidLanguageServer) -> None:
"""`$color-primary` is read in ``buttons.scss`` (`.button-primary` background)
and ``_mixins.scss`` (default value of ``focus-ring``); references invoked
from the ``buttons.scss`` usage site must include both files.
"""
path = "buttons.scss"
needle = "$color-primary"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
refs = language_server.request_references(path, line, col + 2)
ref_paths = {r.get("relativePath", "") for r in refs}
assert any(p.endswith("buttons.scss") for p in ref_paths), (
f"Expected $color-primary references to include buttons.scss, got: {ref_paths}"
)
assert any(p.endswith("_mixins.scss") for p in ref_paths), (
f"Expected $color-primary references to include _mixins.scss (default param of focus-ring), got: {ref_paths}"
)
@pytest.mark.scss
class TestScssForward:
"""`@forward` re-exports a module; consumers should reach forwarded symbols."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_forwarded_buttons_appear_in_workspace(self, language_server: SolidLanguageServer) -> None:
"""``main.scss`` does ``@forward "buttons"``; the workspace symbol tree must
still include ``buttons.scss`` selectors so consumers of `main` can navigate.
"""
all_symbols = request_all_symbols(language_server)
button_symbols = [s for s in all_symbols if s.get("location", {}).get("relativePath") == "buttons.scss"]
names = [s["name"] for s in button_symbols]
joined = " | ".join(names)
assert ".button" in joined, f"Expected .button selector to remain reachable via @forward, got: {names}"
@pytest.mark.scss
class TestScssHover:
"""Some Sass returns rich hover content (SassDoc / value preview)."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_hover_on_variable_use(self, language_server: SolidLanguageServer) -> None:
path = "buttons.scss"
needle = "$color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
hover = language_server.request_hover(path, line, col + 2)
assert hover is not None, f"Expected hover info for $color-text in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "color-text" in text, f"Expected '$color-text' or its value in hover text, got: {text}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_hover_on_mixin_call(self, language_server: SolidLanguageServer) -> None:
path = "buttons.scss"
needle = "card-surface"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
hover = language_server.request_hover(path, line, col + 2)
assert hover is not None, f"Expected hover info for card-surface in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "card-surface" in text, f"Expected 'card-surface' in hover text, got: {text}"
@pytest.mark.scss
class TestScssCompletions:
"""Completions after a namespaced @use prefix should list re-exported members."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_completion_after_namespace_dot(self, language_server: SolidLanguageServer) -> None:
"""Completion immediately after `vars.` in buttons.scss must include the
variables defined in _variables.scss (e.g. ``$color-primary``).
"""
path = "buttons.scss"
# `color: vars.$color-text;` — invoke completion at the `.` position so the
# LSP sees the namespace prefix and offers its members.
needle = "vars.$color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Position the cursor right after the `.` (4 chars: 'v','a','r','s','.' -> idx 5)
completions = language_server.request_completions(path, line, col + 5)
labels = {c.get("completionText", "") for c in completions}
# Different Some Sass releases label vars with or without leading $; accept both.
joined = " | ".join(sorted(labels))
assert any("color-primary" in label for label in labels), f"Expected $color-primary completion after `vars.` prefix, got: {joined}"
@pytest.mark.scss
class TestScssSymbolKinds:
"""Validate that Some Sass classifies SCSS symbols with sensible LSP kinds."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_variable_symbol_kind(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("_variables.scss").get_all_symbols_and_roots()
by_name = {s["name"]: s for s in all_symbols}
assert "$color-primary" in by_name, f"Variable not in symbol list: {list(by_name)}"
kind = SymbolKind(by_name["$color-primary"]["kind"])
assert kind in (SymbolKind.Variable, SymbolKind.Constant, SymbolKind.Property), (
f"Expected $color-primary to be Variable/Constant/Property, got {kind.name}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_mixin_and_function_symbol_kinds(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("_mixins.scss").get_all_symbols_and_roots()
# Names may include the @-keyword (e.g. "@mixin card-surface") or be bare;
# match by substring.
def find_one(needle: str) -> dict:
matches = [s for s in all_symbols if needle in s["name"]]
assert matches, f"No symbol matched '{needle}' in {[s['name'] for s in all_symbols]}"
return matches[0]
mixin = find_one("card-surface")
func = find_one("rem")
# Some Sass historically reports mixins/functions as Method or Function.
# Accept either, but reject obviously-wrong kinds (e.g. Variable/Class).
callable_kinds = {SymbolKind.Method, SymbolKind.Function}
assert SymbolKind(mixin["kind"]) in callable_kinds, (
f"Expected card-surface kind in {{Method, Function}}, got {SymbolKind(mixin['kind']).name}"
)
assert SymbolKind(func["kind"]) in callable_kinds, f"Expected rem kind in {{Method, Function}}, got {SymbolKind(func['kind']).name}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_completion_kind_is_meaningful(self, language_server: SolidLanguageServer) -> None:
"""A `$variable` completion must come back with a meaningful kind. Some Sass
tags color-valued variables as ``Color`` (so editors render swatches) and
non-color variables as ``Variable``/``Property``/``Constant`` — accept any
of these but reject generic ``Text`` which would indicate the LSP failed
to classify the completion.
"""
path = "buttons.scss"
needle = "vars.$color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
completions = language_server.request_completions(path, line, col + 5)
var_items = [c for c in completions if "color-primary" in c.get("completionText", "")]
assert var_items, "no $color-primary completion item found"
accepted = {
LSPTypes.CompletionItemKind.Variable,
LSPTypes.CompletionItemKind.Property,
LSPTypes.CompletionItemKind.Constant,
LSPTypes.CompletionItemKind.Color,
LSPTypes.CompletionItemKind.Value,
}
kinds = {LSPTypes.CompletionItemKind(c["kind"]).name for c in var_items}
assert any(LSPTypes.CompletionItemKind(c["kind"]) in accepted for c in var_items), (
f"Expected variable-like CompletionItemKind for $color-primary, got: {kinds}"
)
# --- Plain CSS via Some Sass --------------------------------------------------
#
# Some Sass advertises the LSP ``css`` languageId as a first-class consumer (see
# ``packages/language-services/src/language-services-types.ts:LanguageConfiguration``)
# and dispatches per-feature handlers via the ``somesass.css.*.enabled`` toggles —
# all of which default to ``false`` upstream and which Serena flips on at init.
# These tests verify the routing actually works end-to-end against a small CSS
# fixture under ``css/`` in the same SCSS test repo.
@pytest.mark.scss
class TestSomeSassWithPlainCss:
"""``Language.SCSS`` also handles plain ``.css`` via ``some-sass-language-server``."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_main_css_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""Each top-level rule selector in ``main.css`` must surface as a document symbol."""
all_symbols, _ = language_server.request_document_symbols("css/main.css").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
joined = " | ".join(names)
for selector in ("body", "#page-header", "#site-title", ".button", ".button-primary", ".button-secondary"):
assert selector in joined, f"Expected selector '{selector}' to appear in CSS symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_theme_css_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""``theme.css`` contains a single ``:root`` block; the LS must report it as a symbol."""
all_symbols, _ = language_server.request_document_symbols("css/theme.css").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
joined = " | ".join(names)
assert ":root" in joined, f"Expected ':root' selector to appear in CSS symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_full_symbol_tree_includes_css_files(self, language_server: SolidLanguageServer) -> None:
"""The ``.css`` files alongside the SCSS workspace must populate the workspace symbol tree."""
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
# `relativePath` uses OS-native separators (cf. test_symbol_retrieval.py),
# so build expected paths via os.path.join to keep this test cross-platform.
for f in (os.path.join("css", "main.css"), os.path.join("css", "reset.css"), os.path.join("css", "theme.css")):
assert f in relative_paths, f"Expected {f} to appear in symbol tree"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_hover_on_css_property(self, language_server: SolidLanguageServer) -> None:
"""Hover on a CSS property name must produce non-empty MDN-backed content
(Some Sass forwards ``vscode-css-languageservice``'s property reference data).
"""
path = "css/main.css"
needle = "background-color"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
hover = language_server.request_hover(path, line, col + 2)
assert hover is not None, f"Expected hover info for background-color in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "background" in text.lower(), f"Expected hover text to mention 'background', got: {text}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_property_completion_in_css_rule(self, language_server: SolidLanguageServer) -> None:
"""Inside a CSS rule body the LS must offer standard property names —
proves ``somesass.css.completion.enabled = true`` is being honoured.
"""
path = "css/main.css"
needle = "padding:"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
completions = language_server.request_completions(path, line, col)
labels = {c.get("completionText", "") for c in completions}
assert any(label in labels for label in ("padding", "margin", "color", "border")), (
f"Expected at least one common CSS property name in completions, got sample: {sorted(labels)[:20]}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_cross_file_completion_for_css_custom_property(self, language_server: SolidLanguageServer) -> None:
"""Completion inside a ``var(...)`` call in ``main.css`` must surface the
``--color-*`` custom properties declared in ``theme.css``.
This is the single test that empirically validates Some Sass crosses file
boundaries for plain CSS — without ``somesass.css.completion.enabled = true``
the request would short-circuit to ``null`` at the handler entrypoint.
Note: ``vscode-css-languageservice`` (Some Sass' CSS engine) deliberately
does NOT implement go-to-definition for CSS custom properties yet — see
microsoft/vscode-css-languageservice#734. Completion is the closest
upstream-supported API that proves cross-file awareness.
"""
path = "css/main.css"
# `` color: var(--color-text);`` — invoke completion right after the
# leading dashes so the LS treats the request as a partial custom-property
# identifier and offers matching declarations from the workspace.
needle = "var(--color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
cursor_col = col + len("var(--")
completions = language_server.request_completions(path, line, cursor_col)
labels = {c.get("completionText", "") for c in completions}
joined = " | ".join(sorted(labels))
assert any("--color-primary" in label for label in labels), (
f"Expected --color-primary completion (declared cross-file in theme.css), got: {joined}"
)
@@ -0,0 +1,42 @@
"""
Diagnostics tests for the SCSS / Sass / CSS language server (Some Sass).
Two sources are exercised — the SCSS parser (undefined ``$variable``) and the
plain-CSS parser (syntax error inside a rule body). ``somesass.css.diagnostics.enabled``
is flipped on at initialization so the .css path is not gated off; lint diagnostics
are deliberately left disabled (the upstream rules are opinionated about vendor
prefixes / empty rules / etc.) so only syntax-level errors surface here.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.scss
class TestScssDiagnostics:
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_scss_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
assert_file_diagnostics(
language_server,
"diagnostics_sample.scss",
(),
min_count=1,
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_plain_css_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""Plain ``.css`` diagnostics flow through the same Some Sass server.
Without ``somesass.css.diagnostics.enabled = true`` (which Serena pushes via
initializationOptions), this would short-circuit to an empty list at the
handler entrypoint.
"""
assert_file_diagnostics(
language_server,
"css/diagnostics_sample.css",
(),
min_count=1,
)