mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-03 07:13:33 +00:00
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:
53 files changed
+3280
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>© 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;
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Whitespace-only changes.
@@ -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
|
||||
Whitespace-only changes.
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user