Merge branch 'main' into hook_extension

# Conflicts:
#	src/solidlsp/ls_config.py
This commit is contained in:
Michael Panchenko committed 2026-05-05 17:24:30 +02:00
commit 53d5e6e898
74 files changed
+3962 -259

No files matched your search

+7
View File
@@ -564,6 +564,13 @@ jobs:
uses: leanprover/lean-action@v1
with:
lake-package-directory: test/resources/repos/lean4/test_repo
- name: Install npm deps for Angular test repo
shell: bash
run: |
if [ -d test/resources/repos/angular/test_repo ]; then
cd test/resources/repos/angular/test_repo
npm install --no-audit --no-fund --loglevel=warn
fi
- name: Cache language servers
id: cache-language-servers
uses: actions/cache@v3
+7 -1
View File
@@ -7,7 +7,8 @@ Status of the `main` branch. Changes prior to the next official version change w
Instead, they can define `added_modes` to add modes on top of base and default modes.
See updated [documentation on modes](https://oraios.github.io/serena/02-usage/050_configuration.html#modes).
- Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`.
- Add cross-package reference support via `additional_workspace_folders` setting (currently implemented for TypeScript).
* JetBrains:
- Add new tools:
- `jet_brains_list_inspections`: Lists available IDE inspections (akin to diagnostics), optionally filtered by language or group
@@ -21,7 +22,12 @@ Status of the `main` branch. Changes prior to the next official version change w
- `get_diagnostics_for_symbol`: Retrieves diagnostics pertaining to a specific symbol
* 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
- Java (eclipse.jdt.ls): Lombok-generated methods (getters/setters, builder(), equals/hashCode/toString, etc.) are now included in symbol-based tools (find_symbol, get_symbols_overview, edits). Added lombok_show_generated setting (default: on) to toggle this. Updated bundled vscode-java to 1.54.0-923. Issue #1432.
- 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.
* Hooks:
- `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports
+1 -1
View File
@@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language
The underlying language servers are typically open-source projects or at least freely available for use.
When using Serena's language server backend, we provide **support for over 40 programming languages**, including
AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
AL, Angular, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
### The Serena JetBrains Plugin
@@ -29,6 +29,10 @@ We currently provide direct, out-of-the-box support for the programming language
Some languages require additional installations or setup steps, as noted.
* **AL**
* **Angular**
(experimental; requires Node.js + npm, plus `npm install` having been run in the project root so that `@angular/core`
is resolvable — without it, template-aware features silently return empty;
subsumes `typescript` and `html` for `.ts`/`.html` files, so do not also list those)
* **Ansible**
(experimental; requires Node.js and npm; automatically installs `@ansible/ansible-language-server`;
must be explicitly specified in the `languages` entry in the `project.yml`; requires `ansible` in PATH for full functionality)
@@ -71,6 +75,8 @@ Some languages require additional installations or setup steps, as noted.
(uses [shader-language-server](https://github.com/antaalt/shader-sense) (language `hlsl`); automatically downloaded;
on macOS, requires Rust toolchain for building from source;
note: reference search is not supported by this language server)
* **HTML**
(experimental; requires Node.js + npm)
* **Java**
* **JavaScript**
(supported via the TypeScript language server, i.e. use language `typescript` for both JavaScript and TypeScript)
@@ -107,6 +113,9 @@ Some languages require additional installations or setup steps, as noted.
(requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain)
* **Scala**
(requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP)
* **SCSS / Sass / CSS**
(experimental; requires Node.js + npm; uses [some-sass-language-server](https://github.com/wkillerud/some-sass) to handle
`.scss`, `.sass`, and `.css`)
* **Solidity**
(experimental; requires Node.js and npm; automatically installs `@nomicfoundation/solidity-language-server`;
works best with a `foundry.toml` or `hardhat.config.js` in the project root)
+28
View File
@@ -51,6 +51,7 @@ The file allows you to configure ...
* the encoding used in source files
* ignore rules
* write access
* [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos
* an initial prompt that shall be passed to the LLM whenever the project is activated
* the set of tools and modes to use for the project
* and some other settings.
@@ -68,6 +69,33 @@ You can specify local overrides for the settings in a `project.local.yml` file i
(which, by default, is ignored by git).
Any keys defined therein will override the respective key in `project.yml`.
(additional-workspace-folders)=
#### Additional Workspace Folders (Cross-Package References)
In monorepos or multi-package setups, Serena's language server normally only sees symbols within the
project root. To enable cross-package references (e.g. `find_referencing_symbols` discovering usages
in sibling packages), configure `additional_workspace_folders` in your `project.yml`:
```yaml
additional_workspace_folders:
- ../shared-lib
- ../api-client
- /absolute/path/to/another-package
```
Paths can be absolute or relative to the project root. Each folder is registered as an LSP workspace
folder, and the language server will discover symbols and references across all listed packages.
**Currently supported for:** TypeScript. Other language servers will raise an error if this setting
is used with them. Support for additional languages can be added by implementing the
`_find_representative_source_file()` method in the respective language server class.
:::{note}
Each additional workspace folder adds startup time, as the language server needs to index the
additional projects. For large monorepos, consider listing only the packages you actively need
cross-references for.
:::
(indexing)=
### Indexing
+82 -4
View File
@@ -227,8 +227,13 @@ ls_specific_settings:
This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`,
and by some additional wrappers that explicitly expose `ls_path`.
Common examples include: `ansible`, `bash`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `kotlin`, `lean4`, `luau`, `markdown`, `php`,
`php_phpactor`, `python`, `rust`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`.
Common examples include: `ansible`, `bash`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`,
`php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`.
Note: `angular` does **not** support `ls_path` — the Angular language server is part of a multi-process orchestration
(`ngserver` plus a companion TypeScript language server with the `@angular/language-service` plugin and an HTML
companion) where the dependency layout matters; use the version overrides documented in the Angular section below
to pin specific releases of the bundled stack.
If a language server supports `ls_path`, setting it bypasses Serena's managed download or install for that server.
In that case, any server-specific version or registry settings only apply when `ls_path` is not set.
@@ -243,6 +248,36 @@ Supported settings:
|---|---|---|
| `al_extension_version` | `18.0.2242655` | Override the AL VS Code extension version Serena downloads from the VS Code Marketplace. |
#### Angular
Serena uses `@angular/language-server` (`ngserver`) for the `angular` language key, orchestrated together with a
companion `typescript-language-server` (with `@angular/language-service` loaded as a tsserver plugin) and a
companion `vscode-html-language-server` for `.html` `documentSymbol`. This is an **experimental** language and
must be explicitly listed in `project.yml`; it is not auto-detected.
**Project requirements:**
- The project itself must have `@angular/core` installed (i.e. `npm install` must have been run in the project root,
or in a workspace root above it for monorepo layouts). Without it, `ngserver` reports every file as "not in an
Angular project" and template-aware features silently return empty.
- A `tsconfig.json` must be reachable at or above any opened `.ts` file.
- Do **not** also list `typescript` or `html` in `languages` when `angular` is active — Angular subsumes both
for `.ts` / `.html` files. SCSS is **not** subsumed; list `scss` separately if needed.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `angular_language_server_version` | `21.2.10` | Override the bundled `@angular/language-server` npm package version Serena installs. |
| `angular_language_service_version` | `21.2.10` | Override the bundled `@angular/language-service` tsserver plugin version. |
| `typescript_version` | `5.9.3` | Override the bundled `typescript` npm package version. Falls back to `ls_specific_settings.typescript.typescript_version` if unset. |
| `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` version. Falls back to `ls_specific_settings.typescript.typescript_language_server_version` if unset. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. Falls back to `ls_specific_settings.typescript.npm_registry` if unset. |
Notes:
- The HTML companion (`vscode-html-language-server`) is configured via `ls_specific_settings.html` — see the HTML section below.
- `ls_path` is not supported (see note above the AL section).
#### Ansible
Serena uses `@ansible/ansible-language-server` for the `ansible` language key.
@@ -496,6 +531,25 @@ ls_specific_settings:
renameSourceFolders: ["src", "lib"]
```
#### HTML
Serena uses `vscode-html-language-server` from Microsoft's `vscode-langservers-extracted` npm package for the
`html` language key. **Experimental** — must be explicitly listed in `project.yml`; not auto-detected. The HTML
LSP returns in-file element / id symbols via `documentSymbol`; cross-file `definition` / `references` are not
meaningful for HTML and are not exposed.
This same language server is also used as a tertiary companion by the Angular language server (see the Angular
section), since `ngserver` does not implement `textDocument/documentSymbol` for `.html` files.
Supported settings:
| Setting | Default | Description |
|---|---|---|
| `ls_path` | managed install | Override the `vscode-html-language-server` executable path. |
| `vscode_langservers_package` | `vscode-langservers-extracted` | npm package providing the binary. Set to `@t1ckbase/vscode-langservers-extracted` (or any other source) to use the actively-maintained 2026 fork. |
| `vscode_langservers_version` | `4.10.0` | Override the npm package version Serena installs when `ls_path` is not set. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. |
#### Java (`eclipse.jdt.ls`)
Java support has two installation modes:
@@ -535,8 +589,9 @@ The following settings are supported for the Java language server:
| `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses the bundled JRE. |
| `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. |
| `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. |
| `vscode_java_version` | `1.42.0-561` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. |
| `vscode_java_version` | `1.54.0-923` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. |
| `intellicode_version` | `1.2.30` | (vscode-java mode only) Override the IntelliCode VSIX version Serena downloads by default. |
| `lombok_show_generated` | `true` | Show Lombok-generated methods (`getX/setX`, `builder()`, `equals/hashCode/toString`, `withX`, fluent accessors) in `find_symbol`, `get_symbols_overview` and the symbol-edit tools. Set to `false` to restore the previous JDTLS default and hide the synthetic methods (e.g. when `@Data` classes pollute the outline with too many getters/setters). Requires JDTLS commit `b2d8952` / `vscode-java >= 1.53.0`; the bundled default already meets this. |
| `jdtls_xmx` | `3G` | Maximum heap size for the JDTLS server JVM. |
| `jdtls_xms` | `100m` | Initial heap size for the JDTLS server JVM. |
| `intellicode_xmx` | `1G` | (vscode-java mode only) Maximum heap size for the IntelliCode embedded JVM. |
@@ -559,7 +614,7 @@ Example: upstream-jdtls mode (offline / corporate network):
ls_specific_settings:
java:
jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"
lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar"
lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.38/lombok-1.18.38.jar"
# java_home: "/opt/homebrew/opt/openjdk@21" # optional
```
@@ -790,6 +845,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.
@@ -847,6 +922,9 @@ Supported settings:
| `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. |
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. |
TypeScript supports [additional workspace folders](additional-workspace-folders) for cross-package
reference discovery. Configure `additional_workspace_folders` in `project.yml` to enable this feature.
#### TypeScript via `vtsls`
The actual configuration key for vtsls is `typescript_vts`, not `vts`.
+3
View File
@@ -142,10 +142,13 @@ Only the language servers listed below download or install additional dependenci
### npm Package Installs
- **Angular**: `@angular/language-server`, `@angular/language-service`, plus `typescript` and `typescript-language-server`
- **Ansible**: `@ansible/ansible-language-server`
- **Bash**: `bash-language-server`
- **Elm**: `@elm-tooling/elm-language-server`
- **HTML**: `vscode-langservers-extracted` (provides `vscode-html-language-server`)
- **PHP (`intelephense`)**: `intelephense`
- **SCSS / Sass / CSS**: `some-sass-language-server`
- **Solidity**: `@nomicfoundation/solidity-language-server`
- **TypeScript**: `typescript` and `typescript-language-server`
- **Vue**: `@vue/language-server`, plus `typescript` and `typescript-language-server`
+3
View File
@@ -349,6 +349,9 @@ markers = [
"solidity: language server running for Solidity (uses @nomicfoundation/solidity-language-server)",
"ansible: language server running for Ansible (uses @ansible/ansible-language-server)",
"msl: language server running for mSL (mIRC Scripting Language)",
"html: language server running for HTML (vscode-html-language-server, used as companion by Angular)",
"scss: language server running for SCSS / Sass / CSS (some-sass-language-server)",
"angular: language server running for Angular (uses @angular/language-server + @angular/language-service tsserver plugin)",
]
[tool.codespell]
+3
View File
@@ -271,6 +271,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
project_name: str
languages: list[Language]
ignored_paths: list[str] = field(default_factory=list)
additional_workspace_folders: list[str] = field(default_factory=list)
read_only: bool = False
ignore_all_files_in_gitignore: bool = True
initial_prompt: str = ""
@@ -482,6 +483,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
fixed_tools = data["fixed_tools"] or []
excluded_tools = data["excluded_tools"] or []
included_optional_tools = data["included_optional_tools"] or []
additional_workspace_folders = data.get("additional_workspace_folders") or []
if "base_modes" in data and data["base_modes"] is not None:
log.warning("The base_modes setting in project.yml is deprecated and will be ignored.")
@@ -490,6 +492,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes):
project_name=data["project_name"],
languages=languages,
ignored_paths=ignored_paths,
additional_workspace_folders=additional_workspace_folders,
excluded_tools=excluded_tools,
fixed_tools=fixed_tools,
included_optional_tools=included_optional_tools,
+3
View File
@@ -27,6 +27,7 @@ class LanguageServerFactory:
ignored_patterns: list[str],
ls_timeout: float | None = None,
ls_specific_settings: dict | None = None,
additional_workspace_folders: list[str] | None = None,
trace_lsp_communication: bool = False,
):
self.project_root = project_root
@@ -35,6 +36,7 @@ class LanguageServerFactory:
self.ignored_patterns = ignored_patterns
self.ls_timeout = ls_timeout
self.ls_specific_settings = ls_specific_settings
self.additional_workspace_folders = additional_workspace_folders or []
self.trace_lsp_communication = trace_lsp_communication
def create_language_server(self, language: Language) -> SolidLanguageServer:
@@ -54,6 +56,7 @@ class LanguageServerFactory:
solidlsp_dir=SerenaPaths().serena_user_home_dir,
project_data_path=self.project_data_path,
ls_specific_settings=self.ls_specific_settings or {},
additional_workspace_folders=self.additional_workspace_folders,
),
)
+1
View File
@@ -660,6 +660,7 @@ class Project(ToStringMixin):
ignored_patterns=self._ignored_patterns,
ls_timeout=ls_timeout,
ls_specific_settings=ls_specific_settings,
additional_workspace_folders=self.project_config.additional_workspace_folders,
trace_lsp_communication=self.serena_config.trace_lsp_communication,
)
self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory)
+25 -12
View File
@@ -3,24 +3,26 @@ project_name: "project_name"
# list of languages for which language servers are started; choose from:
# al ansible bash clojure cpp
# cpp_ccls crystal csharp csharp_omnisharp dart
# elixir elm erlang fortran fsharp
# go groovy haskell haxe hlsl
# java json julia kotlin lean4
# lua luau markdown matlab msl
# nix ocaml pascal perl php
# php_phpactor powershell python python_jedi python_ty
# r rego ruby ruby_solargraph rust
# scala solidity swift systemverilog terraform
# toml typescript typescript_vts vue yaml
# zig
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# swift systemverilog terraform toml typescript
# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
@@ -55,6 +57,17 @@ ignore_all_files_in_gitignore: true
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
@@ -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
)
+101 -32
View File
@@ -38,6 +38,7 @@ INTELLICODE_ALLOWED_HOSTS = (
"download.visualstudio.microsoft.com",
)
# Version pinning convention (read this before bumping anything below):
#
# INITIAL_* — the very first version we shipped runtime-dependency support for, paired with
@@ -62,6 +63,22 @@ INTELLICODE_ALLOWED_HOSTS = (
# custom_settings and the matching cached subdir is picked up without re-downloading.
# - INITIAL_* and DEFAULT_* hold identical literals when first introduced; they diverge on
# the first DEFAULT_* bump and stay independent thereafter.
@dataclasses.dataclass(frozen=True)
class VsixResourcePaths:
"""
Paths to resources inside the vscode-java VSIX archive. These vary between vscode-java
releases (JRE bundle, Lombok jar, Equinox launcher all have versioned filenames inside
the VSIX), so we pin them per-version alongside SHA256 — bumping the VSIX version means
bumping these too. Without this, a user requesting a non-DEFAULT pinned version would
extract the right archive but then crash on FileNotFoundError trying to find the wrong
JRE / Lombok / launcher inside it.
"""
jre_version: str # e.g. "21.0.7"; expanded to extension/jre/{jre_version}-{platform-suffix}
lombok_jar_basename: str # e.g. "lombok-1.18.36.jar"
equinox_launcher_basename: str # e.g. "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"
INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561"
INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = {
"osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933",
@@ -70,14 +87,26 @@ INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = {
"linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9",
"win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3",
}
DEFAULT_VSCODE_JAVA_VERSION = "1.42.0-561"
INITIAL_VSCODE_JAVA_PATHS = VsixResourcePaths(
jre_version="21.0.7",
lombok_jar_basename="lombok-1.18.36.jar",
equinox_launcher_basename="org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
)
# Bumped from 1.42.0-561 to surface Lombok-generated methods (#1432); brings JDTLS commit b2d8952
# (java.symbols.includeGeneratedCode), JRE 21.0.10, Lombok 1.18.39 and Equinox launcher 1.7.100.
DEFAULT_VSCODE_JAVA_VERSION = "1.54.0-923"
DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = {
"osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933",
"osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f",
"linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1",
"linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9",
"win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3",
"osx-arm64": "c54c45cb0d2579d8e0a4ddeb24d4a9dd0b460d07d9366adea2b38a1da22a463c",
"osx-x64": "dfc98abc4e54165a78372e280242a039671729b1b03420608df3b10c6b629fb6",
"linux-arm64": "e2bb22c427d90da8dbb1afff72ff1e2dce38d50b76deb02d7bc313a330a1330c",
"linux-x64": "9d4b15da54e25a0192f9bac073f086c015397d3676623b68dbf83a5dbaf5132b",
"win-x64": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4",
}
DEFAULT_VSCODE_JAVA_PATHS = VsixResourcePaths(
jre_version="21.0.10",
lombok_jar_basename="lombok-1.18.39-4050.jar",
equinox_launcher_basename="org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar",
)
INITIAL_INTELLICODE_VERSION = "1.2.30"
INITIAL_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251"
@@ -156,8 +185,16 @@ class EclipseJDTLS(SolidLanguageServer):
- jdtls_xms: Initial heap size for the JDTLS server JVM (default: "100m")
- intellicode_xmx: Maximum heap size for the IntelliCode embedded JVM (default: "1G")
- intellicode_xms: Initial heap size for the IntelliCode embedded JVM (default: "100m")
- lombok_show_generated: Show Lombok-generated methods (getX/setX/builder()/...) in document
symbols by sending java.symbols.includeGeneratedCode=true to JDTLS (default: true).
Set to false for @Data-heavy projects where the extra getters/setters are noise.
Requires JDTLS commit b2d8952+ (vscode-java >= 1.53.0); older servers ignore the
key silently. See eclipse-jdtls/eclipse.jdt.ls#3706 and serena #1432.
- gradle_version: Override the pinned Gradle distribution version downloaded by Serena
- vscode_java_version: Override the pinned vscode-java runtime bundle version downloaded by Serena
- vscode_java_version: Override the pinned vscode-java runtime bundle version downloaded by Serena.
Pinned versions: "1.54.0-923" (default) and "1.42.0-561" (legacy / initial). Other versions
are not supported in default VSIX mode (the resource paths inside the archive change between
releases); use upstream-jdtls mode for arbitrary versions.
- intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena
Example configuration for upstream JDTLS mode (no downloads, suitable for offline/corporate):
@@ -184,8 +221,9 @@ class EclipseJDTLS(SolidLanguageServer):
jdtls_xms: "100m" # initial heap size for the JDTLS server JVM
intellicode_xmx: "1G" # maximum heap size for the IntelliCode embedded JVM
intellicode_xms: "100m" # initial heap size for the IntelliCode embedded JVM
lombok_show_generated: true # show Lombok-generated methods in document symbols (default true)
gradle_version: "8.14.2"
vscode_java_version: "1.42.0-561"
vscode_java_version: "1.54.0-923" # also accepts pinned legacy "1.42.0-561"
intellicode_version: "1.2.30"
```
"""
@@ -305,6 +343,23 @@ class EclipseJDTLS(SolidLanguageServer):
return DEFAULT_INTELLICODE_SHA256
return None
# Resolve internal VSIX paths (JRE / Lombok / launcher filenames). For pinned versions
# these are known; for any other user-supplied version we bail out — guessing would
# silently produce broken paths at JDTLS launch time, which is a worse UX than failing
# fast here with a pointer to upstream-JDTLS mode (which doesn't need pinned paths).
if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION:
vsix_paths = INITIAL_VSCODE_JAVA_PATHS
elif vscode_java_version == DEFAULT_VSCODE_JAVA_VERSION:
vsix_paths = DEFAULT_VSCODE_JAVA_PATHS
else:
raise SolidLSPException(
f"Resource paths inside the vscode-java {vscode_java_version} VSIX are not pinned in serena "
f"(known: {INITIAL_VSCODE_JAVA_VERSION}, {DEFAULT_VSCODE_JAVA_VERSION}). "
f"Either remove the 'vscode_java_version' override (defaults to {DEFAULT_VSCODE_JAVA_VERSION}), "
f"or use upstream JDTLS mode by setting both 'jdtls_path' and 'lombok_path' in "
f"ls_specific_settings.java (no pinning required)."
)
runtime_dependencies: dict[str, dict[str, dict[str, object]]] = {
"gradle": {
"platform-agnostic": {
@@ -329,10 +384,10 @@ class EclipseJDTLS(SolidLanguageServer):
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("osx-arm64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-macosx-aarch64",
"jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jre_home_path": f"extension/jre/{vsix_paths.jre_version}-macosx-aarch64",
"jre_path": f"extension/jre/{vsix_paths.jre_version}-macosx-aarch64/bin/java",
"lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}",
"jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}",
"jdtls_readonly_config_path": "extension/server/config_mac_arm",
},
"osx-x64": {
@@ -341,10 +396,10 @@ class EclipseJDTLS(SolidLanguageServer):
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("osx-x64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-macosx-x86_64",
"jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jre_home_path": f"extension/jre/{vsix_paths.jre_version}-macosx-x86_64",
"jre_path": f"extension/jre/{vsix_paths.jre_version}-macosx-x86_64/bin/java",
"lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}",
"jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}",
"jdtls_readonly_config_path": "extension/server/config_mac",
},
"linux-arm64": {
@@ -353,10 +408,10 @@ class EclipseJDTLS(SolidLanguageServer):
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("linux-arm64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-linux-aarch64",
"jre_path": "extension/jre/21.0.7-linux-aarch64/bin/java",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jre_home_path": f"extension/jre/{vsix_paths.jre_version}-linux-aarch64",
"jre_path": f"extension/jre/{vsix_paths.jre_version}-linux-aarch64/bin/java",
"lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}",
"jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}",
"jdtls_readonly_config_path": "extension/server/config_linux_arm",
},
"linux-x64": {
@@ -365,10 +420,10 @@ class EclipseJDTLS(SolidLanguageServer):
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("linux-x64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-linux-x86_64",
"jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jre_home_path": f"extension/jre/{vsix_paths.jre_version}-linux-x86_64",
"jre_path": f"extension/jre/{vsix_paths.jre_version}-linux-x86_64/bin/java",
"lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}",
"jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}",
"jdtls_readonly_config_path": "extension/server/config_linux",
},
"win-x64": {
@@ -377,10 +432,10 @@ class EclipseJDTLS(SolidLanguageServer):
"relative_extraction_path": vscode_java_dirname,
"sha256": vscode_java_sha("win-x64"),
"allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS,
"jre_home_path": "extension/jre/21.0.7-win32-x86_64",
"jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe",
"lombok_jar_path": "extension/lombok/lombok-1.18.36.jar",
"jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar",
"jre_home_path": f"extension/jre/{vsix_paths.jre_version}-win32-x86_64",
"jre_path": f"extension/jre/{vsix_paths.jre_version}-win32-x86_64/bin/java.exe",
"lombok_jar_path": f"extension/lombok/{vsix_paths.lombok_jar_basename}",
"jdtls_launcher_jar_path": f"extension/server/plugins/{vsix_paths.equinox_launcher_basename}",
"jdtls_readonly_config_path": "extension/server/config_win",
},
},
@@ -883,6 +938,15 @@ class EclipseJDTLS(SolidLanguageServer):
intellicode_xmx = self._custom_settings.get("intellicode_xmx", "1G")
intellicode_xms = self._custom_settings.get("intellicode_xms", "100m")
# Lombok-generated symbols (getX/setX/builder()/equals/hashCode/toString/...): JDTLS filters
# these out of documentSymbol results by default. Without them, find_symbol/get_symbols_overview
# return only user-written sources, which breaks navigation around @Data/@Builder/@Getter/@Setter
# classes. Default to True so agentic workflows can find_symbol -> replace_symbol_body on generated
# members. Requires JDTLS commit b2d8952 or newer (vscode-java >= 1.53.0, or upstream JDTLS
# snapshot after 2026-02-24); older servers ignore the unknown preference key silently.
# See eclipse-jdtls/eclipse.jdt.ls#3706.
lombok_show_generated = self._custom_settings.get("lombok_show_generated", True)
# Gradle wrapper: default to False to preserve existing behaviour
gradle_wrapper_enabled = self._custom_settings.get("gradle_wrapper_enabled", False)
log.info(
@@ -1096,9 +1160,10 @@ class EclipseJDTLS(SolidLanguageServer):
"defaultMojoExecutionAction": "ignore",
},
"workspaceCacheLimit": 90,
"runtimes": [
{"name": "JavaSE-21", "path": "static/vscode-java/extension/jre/21.0.7-linux-x86_64", "default": True}
],
# Placeholder; the real `runtimes` array is overwritten below from
# self.runtime_dependency_paths.jre_home_path (works for both default
# VSIX mode and upstream-jdtls mode, which uses the system JDK).
"runtimes": [],
},
"trace": {"server": "verbose"},
"import": {
@@ -1162,7 +1227,11 @@ class EclipseJDTLS(SolidLanguageServer):
"references": {"includeAccessors": True, "includeDecompiledSources": True},
"typeHierarchy": {"lazyLoad": False},
"settings": {"url": None},
"symbols": {"includeSourceMethodDeclarations": False},
"symbols": {
"includeSourceMethodDeclarations": False,
# Surface Lombok-generated methods in document symbols (#1432).
"includeGeneratedCode": lombok_show_generated,
},
"inlayHints": {"parameterNames": {"enabled": "literals", "exclusions": []}},
"codeAction": {"sortMembers": {"avoidVolatileChanges": True}},
"compile": {
@@ -225,6 +225,36 @@ class ElixirTools(SolidLanguageServer):
def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str:
return symbol["name"].removeprefix("defp ").removeprefix("def ").split("(", 1)[0].strip()
def _find_mix_exs(self) -> str | None:
"""
Find mix.exs in the repository.
Checks {repository_root_path}/mix.exs first (standard layout), then scans
immediate subdirectories for mix.exs (monorepo layout, e.g. server/mix.exs).
Returns the absolute path to the first match found, or None if not found.
Note: subdirectory iteration order depends on the filesystem (os.listdir).
In practice this is fine — monorepos typically have one Elixir app.
"""
# Check root first
root_mix_exs = os.path.join(self.repository_root_path, "mix.exs")
if os.path.exists(root_mix_exs):
return root_mix_exs
# Search immediate subdirectories
try:
for entry in os.listdir(self.repository_root_path):
subdir_path = os.path.join(self.repository_root_path, entry)
if os.path.isdir(subdir_path):
subdir_mix_exs = os.path.join(subdir_path, "mix.exs")
if os.path.exists(subdir_mix_exs):
log.info(f"Found mix.exs in subdirectory: {entry}/mix.exs")
return subdir_mix_exs
except OSError as e:
log.warning(f"Error scanning for mix.exs in subdirectories: {e}")
return None
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
@@ -364,10 +394,11 @@ class ElixirTools(SolidLanguageServer):
# Without it Expert sits idle and never emits the $/progress signals we wait for,
# causing a deadlock. Opening mix.exs is the minimal trigger; we close it again
# once the server is ready so it does not linger in the open-file set.
mix_exs_path = os.path.join(self.repository_root_path, "mix.exs")
mix_exs_uri = pathlib.Path(mix_exs_path).as_uri() if os.path.exists(mix_exs_path) else None
mix_exs_path = self._find_mix_exs()
mix_exs_uri: str | None = None
if mix_exs_uri is not None:
if mix_exs_path is not None:
mix_exs_uri = pathlib.Path(mix_exs_path).as_uri()
with open(mix_exs_path, encoding="utf-8") as f:
mix_exs_content = f.read()
self.server.notify.did_open_text_document(
@@ -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()
@@ -261,12 +261,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
"workspaceFolders": self._build_workspace_folders_param(repository_absolute_path),
}
return cast(InitializeParams, initialize_params)
@@ -407,6 +402,45 @@ class TypeScriptLanguageServer(SolidLanguageServer):
self.INDEXING_PROGRESS_TIMEOUT,
)
self._activate_additional_workspaces()
@override
def _find_representative_source_file(self, directory: str) -> str | None:
"""Find a TypeScript file suitable for triggering project loading.
Prefers a file adjacent to tsconfig.json (indicating the project root),
then falls back to the first .ts/.tsx file found.
"""
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)]
if "tsconfig.json" in files:
for f in files:
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(root, f)
src_dir = os.path.join(root, "src")
if os.path.isdir(src_dir):
for f in os.listdir(src_dir):
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(src_dir, f)
for root, dirs, files in os.walk(directory):
dirs[:] = [d for d in dirs if not self.is_ignored_dirname(d)]
for f in files:
if f.endswith((".ts", ".tsx")) and not f.endswith(".d.ts"):
return os.path.join(root, f)
return None
@override
def _signal_expect_indexing(self) -> None:
self.expect_indexing()
@override
def _wait_for_additional_workspace_indexing(self) -> None:
if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT):
log.info("Additional workspace indexing complete")
else:
log.warning("Additional workspace indexing did not complete within timeout; proceeding anyway")
@override
def _get_published_diagnostics_uri(self, request_uri: str) -> str:
if os.name != "nt" or not request_uri.startswith("file:///"):
@@ -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:
+165 -23
View File
@@ -576,6 +576,29 @@ class SolidLanguageServer(ABC):
self._has_waited_for_cross_file_references = False
# resolving additional workspace folders
self._additional_workspace_abs_paths: list[str] = []
_seen: set[str] = set()
for additional_workspace_path in self._solidlsp_settings.additional_workspace_folders:
if not additional_workspace_path or additional_workspace_path == ".":
continue
if not os.path.isabs(additional_workspace_path):
additional_workspace_abs_path = os.path.realpath(os.path.join(self.repository_root_path, additional_workspace_path))
else:
additional_workspace_abs_path = str(Path(additional_workspace_path).resolve())
if not os.path.isdir(additional_workspace_abs_path):
log.error(
"additional_workspace_folders: skipping non-existent directory %s (resolved to %s)",
additional_workspace_abs_path,
additional_workspace_abs_path,
)
continue
if additional_workspace_abs_path in _seen:
log.info("additional_workspace_folders: skipping duplicate %s", additional_workspace_path)
continue
_seen.add(additional_workspace_abs_path)
self._additional_workspace_abs_paths.append(additional_workspace_abs_path)
def _observe_server_notification(self, method: str, params: Any) -> None:
"""
Observe notifications sent by the language server.
@@ -983,6 +1006,124 @@ class SolidLanguageServer(ABC):
"""
return 2
# --- Cross-workspace / additional workspace folder support ---
@staticmethod
def _path_contains_dots(relative_file_path: str) -> bool:
"""Check if a relative path traverses outside the workspace root via '..' components."""
return ".." in PurePath(relative_file_path).parts
def _resolve_file_uri(self, relative_file_path: str) -> str:
"""Construct a canonical file URI from a relative path.
For cross-workspace paths containing '..', the path is resolved to
produce a clean URI without '..' segments.
"""
p = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path))
if self._path_contains_dots(relative_file_path):
p = p.resolve()
is_outside_of_configured_workspaces = True
configured_workspaces = [*self._additional_workspace_abs_paths, self.repository_root_path]
for workspace in configured_workspaces:
if p.is_relative_to(workspace):
is_outside_of_configured_workspaces = False
break
if is_outside_of_configured_workspaces:
raise ValueError(
f"Path {relative_file_path} contains '..' segments and is outside of configured workspaces. "
f"Configured workspaces: {configured_workspaces}. Resolved path: {p}."
)
return p.as_uri()
def _build_workspace_folders_param(self, repository_absolute_path: str) -> list[dict[str, str]]:
"""Build the ``workspaceFolders`` list for LSP initialization.
Returns a list containing the primary workspace folder followed by any
additional workspace folders configured in settings.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
folders: list[dict[str, str]] = [
{"uri": root_uri, "name": os.path.basename(repository_absolute_path)},
]
for abs_folder in self._additional_workspace_abs_paths:
folder_uri = pathlib.Path(abs_folder).as_uri()
folders.append({"uri": folder_uri, "name": os.path.basename(abs_folder)})
if len(folders) > 1:
log.info("LSP multi-root workspace: %d folders", len(folders))
return folders
def _activate_additional_workspaces(self) -> None:
"""Open a representative file from each additional workspace folder to
trigger project loading in the language server.
Many language servers only load projects on-demand when files are opened.
This method finds a source file in each additional workspace folder via
:meth:`_find_representative_source_file` and opens it, keeping it open
for the lifetime of this language server instance.
Subclasses must override :meth:`_find_representative_source_file` to
enable this feature; the default implementation raises ``NotImplementedError``.
"""
opened_count = 0
for additional_workspace in self._additional_workspace_abs_paths:
source_file = self._find_representative_source_file(additional_workspace)
if source_file is None:
log.warning("No source file found in additional workspace folder: %s", additional_workspace)
continue
rel_path = os.path.relpath(source_file, self.repository_root_path)
log.info("Opening %s to trigger project loading for %s", rel_path, os.path.basename(additional_workspace))
abs_path = pathlib.Path(source_file).resolve()
uri = abs_path.as_uri()
language_id = self._get_language_id_for_file(rel_path)
self._signal_expect_indexing()
fb = LSPFileBuffer(
abs_path=abs_path,
uri=uri,
encoding=self._encoding,
version=0,
language_id=language_id,
ref_count=1,
language_server=self,
open_in_ls=True,
)
self.open_file_buffers[uri] = fb
opened_count += 1
if opened_count > 0:
log.info("Waiting for %d additional workspace(s) to index...", opened_count)
self._wait_for_additional_workspace_indexing()
def _find_representative_source_file(self, directory: str) -> str | None:
"""Find a source file suitable for triggering project loading in the given directory.
Must be overridden by subclasses that support ``additional_workspace_folders``.
Return ``None`` if no suitable file is found.
:param directory: Absolute path to the workspace folder.
:return: Absolute path to a source file, or None.
"""
raise NotImplementedError(
f"{type(self).__name__} does not yet support additional_workspace_folders. "
f"Override _find_representative_source_file() to enable this feature."
)
def _signal_expect_indexing(self) -> None:
"""Signal that new files are about to be opened and async indexing should be awaited.
Override in subclasses that track indexing progress (e.g. via $/progress).
Default implementation is a no-op.
"""
def _wait_for_additional_workspace_indexing(self) -> None:
"""Wait for additional workspace indexing to complete.
Override in subclasses that track indexing progress.
Default implementation is a no-op.
"""
def set_request_timeout(self, timeout: float | None) -> None:
"""
:param timeout: the timeout, in seconds, for requests to the language server.
@@ -1144,6 +1285,8 @@ class SolidLanguageServer(ABC):
raise SolidLSPException("Language Server not started")
absolute_file_path = Path(self.repository_root_path, relative_file_path)
if self._path_contains_dots(relative_file_path):
absolute_file_path = absolute_file_path.resolve()
uri = absolute_file_path.as_uri()
if uri in self.open_file_buffers:
@@ -1191,7 +1334,7 @@ class SolidLanguageServer(ABC):
be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer.
"""
if file_buffer is not None:
expected_uri = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()
expected_uri = self._resolve_file_uri(relative_file_path)
assert file_buffer.uri == expected_uri, f"Inconsistency between provided {file_buffer.uri=} and {expected_uri=}"
if open_in_ls:
file_buffer.ensure_open_in_ls()
@@ -1214,8 +1357,7 @@ class SolidLanguageServer(ABC):
log.error("insert_text_at_position called before Language Server started")
raise SolidLSPException("Language Server not started")
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
uri = pathlib.Path(absolute_file_path).as_uri()
uri = self._resolve_file_uri(relative_file_path)
# Ensure the file is open
assert uri in self.open_file_buffers
@@ -1257,8 +1399,7 @@ class SolidLanguageServer(ABC):
log.error("delete_text_between_positions called before Language Server started")
raise SolidLSPException("Language Server not started")
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
uri = pathlib.Path(absolute_file_path).as_uri()
uri = self._resolve_file_uri(relative_file_path)
# Ensure the file is open
assert uri in self.open_file_buffers
@@ -1447,9 +1588,7 @@ class SolidLanguageServer(ABC):
return cast(
DefinitionParams,
{
LSPConstants.TEXT_DOCUMENT: {
LSPConstants.URI: pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri()
},
LSPConstants.TEXT_DOCUMENT: {LSPConstants.URI: self._resolve_file_uri(relative_file_path)},
LSPConstants.POSITION: {
LSPConstants.LINE: line,
LSPConstants.CHARACTER: column,
@@ -1496,7 +1635,7 @@ class SolidLanguageServer(ABC):
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
return self.server.send.references(
{
"textDocument": {"uri": PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))},
"textDocument": {"uri": self._resolve_file_uri(relative_file_path)},
"position": {"line": line, "character": column},
"context": {"includeDeclaration": False},
}
@@ -1603,7 +1742,7 @@ class SolidLanguageServer(ABC):
:return: A list of completions
"""
with self.open_file(relative_file_path):
open_file_buffer = self.open_file_buffers[pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()]
open_file_buffer = self.open_file_buffers[self._resolve_file_uri(relative_file_path)]
completion_params: LSPTypes.CompletionParams = {
"position": {"line": line, "character": column},
"textDocument": {"uri": open_file_buffer.uri},
@@ -1720,9 +1859,7 @@ class SolidLanguageServer(ABC):
# no cached result, query language server
log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server")
response = self.server.send.document_symbol(
{"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}}
)
response = self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}})
# Only cache non-empty results. An empty or None response can occur when the language server
# has not yet finished indexing or building the project (e.g. Lean 4 before `lake build`),
@@ -2161,7 +2298,7 @@ class SolidLanguageServer(ABC):
with self.open_file(relative_file_path):
response = self.server.send.signature_help(
{
"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()},
"textDocument": {"uri": self._resolve_file_uri(relative_file_path)},
"position": {
"line": line,
"character": column,
@@ -2272,10 +2409,13 @@ class SolidLanguageServer(ABC):
if containing_symbol is None and include_file_symbols:
log.warning(f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead")
fileRange = self._get_range_from_file_content(file_data.contents)
ref_abs_path = os.path.join(self.repository_root_path, ref_path)
if self._path_contains_dots(ref_path):
ref_abs_path = str(pathlib.Path(ref_abs_path).resolve())
location = ls_types.Location(
uri=str(pathlib.Path(os.path.join(self.repository_root_path, ref_path)).as_uri()),
uri=self._resolve_file_uri(ref_path),
range=fileRange,
absolutePath=str(os.path.join(self.repository_root_path, ref_path)),
absolutePath=ref_abs_path,
relativePath=ref_path,
)
name = os.path.splitext(os.path.basename(ref_path))[0]
@@ -2380,7 +2520,9 @@ class SolidLanguageServer(ABC):
"""
# checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor
with self.open_file(relative_file_path):
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
absolute_file_path = os.path.join(self.repository_root_path, relative_file_path)
if self._path_contains_dots(relative_file_path):
absolute_file_path = str(pathlib.Path(absolute_file_path).resolve())
content = FileUtils.read_file(absolute_file_path, self._encoding)
if content.split("\n")[line].strip() == "":
log.error(f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}")
@@ -2495,7 +2637,9 @@ class SolidLanguageServer(ABC):
return definitions[0]
def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ls_types.UnifiedSymbolInformation]:
absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path))
abs_path = os.path.join(self.repository_root_path, relative_file_path)
if self._path_contains_dots(relative_file_path):
abs_path = str(pathlib.Path(abs_path).resolve())
document_symbols = self.request_document_symbols(relative_file_path)
symbols = list(document_symbols.iter_symbols())
@@ -2503,9 +2647,9 @@ class SolidLanguageServer(ABC):
# symbol exposes a normalized location/range in the current workspace.
for symbol in symbols:
location = symbol["location"]
location["absolutePath"] = absolute_file_path
location["absolutePath"] = abs_path
location["relativePath"] = relative_file_path
location["uri"] = Path(absolute_file_path).as_uri()
location["uri"] = self._resolve_file_uri(relative_file_path)
return symbols
@staticmethod
@@ -2933,9 +3077,7 @@ class SolidLanguageServer(ABC):
:return: A WorkspaceEdit containing the changes needed to rename the symbol, or None if rename is not supported
"""
params = RenameParams(
textDocument=ls_types.TextDocumentIdentifier(
uri=pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()
),
textDocument=ls_types.TextDocumentIdentifier(uri=self._resolve_file_uri(relative_file_path)),
position=ls_types.Position(line=line, character=column),
newName=new_name,
)
+58
View File
@@ -165,6 +165,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, include_non_programming_languages: bool = True) -> Iterable[Self]:
@@ -196,6 +223,9 @@ class Language(str, Enum):
self.GROOVY,
self.CPP_CCLS,
self.SOLIDITY,
self.HTML,
self.SCSS,
self.ANGULAR,
}
def is_programming_language(self) -> bool:
@@ -361,6 +391,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}")
@@ -592,6 +638,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}")
+7
View File
@@ -34,6 +34,13 @@ class SolidLSPSettings:
Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available.
No documentation on options means no options are available.
"""
additional_workspace_folders: list[str] = field(default_factory=list)
"""
Additional workspace folder paths for cross-package reference support.
Paths can be absolute or relative to the project root. Each folder is added as a workspace
folder in the LSP initialization, enabling language servers to discover symbols and references
across package boundaries (e.g. in monorepos).
"""
def __post_init__(self) -> None:
os.makedirs(str(self.solidlsp_dir), exist_ok=True)
+10 -1
View File
@@ -61,6 +61,7 @@ def _create_ls(
ignored_paths: list[str] | None = None,
trace_lsp_communication: bool = False,
ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
additional_workspace_folders: list[str] | None = None,
solidlsp_dir: Path | None = None,
) -> SolidLanguageServer:
ignored_paths = ignored_paths or []
@@ -83,6 +84,7 @@ def _create_ls(
solidlsp_dir=effective_solidlsp_dir,
project_data_path=project_data_path,
ls_specific_settings=ls_specific_settings or {},
additional_workspace_folders=additional_workspace_folders or [],
),
)
@@ -94,9 +96,12 @@ def start_ls_context(
ignored_paths: list[str] | None = None,
trace_lsp_communication: bool = False,
ls_specific_settings: dict[Language, dict[str, Any]] | None = None,
additional_workspace_folders: list[str] | None = None,
solidlsp_dir: Path | None = None,
) -> Iterator[SolidLanguageServer]:
ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, solidlsp_dir)
ls = _create_ls(
language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, additional_workspace_folders, solidlsp_dir
)
log.info(f"Starting language server for {language} {repo_path}")
ls.start()
try:
@@ -272,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],
}
@@ -344,6 +352,7 @@ def languages_supporting_implementation(*languages: Language) -> list[Language]:
_VERIFIED_IMPLEMENTATION_LANGUAGES = {
Language.ANGULAR,
Language.CSHARP,
Language.GO,
Language.JAVA,
@@ -0,0 +1,5 @@
node_modules
.angular
dist
out-tsc
.angular-install.lock
@@ -0,0 +1,24 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"serena-test-app": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/serena-test-app",
"index": "src/index.html",
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json"
}
}
}
}
}
}
+134
View File
@@ -0,0 +1,134 @@
{
"name": "serena-angular-test-repo",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "serena-angular-test-repo",
"version": "0.0.0",
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.6.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"typescript": "~5.9.0"
}
},
"node_modules/@angular/common": {
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.10.tgz",
"integrity": "sha512-WLyi/CRLtgALg2mmaqIuKuPnE4i+8PGt/uuz26pVqx+ASh28/TWr5KSCAMomgxEc8kt4OE7lopoQsTihrQCfEw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "21.2.10",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.10.tgz",
"integrity": "sha512-IrgdFuzzD7NTK3WQaSfowjAPxPbnTqsgR92NsOs5ZaWu3RgLl21dHThNc0BK1KwVwppLUSWmD4qePbcLW71VzQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@angular/core": {
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.10.tgz",
"integrity": "sha512-uxH+mbPiCE7rInWKYOPe9Ytas97+mFM6FhFORoN234yBK3b8he+iDuxX6dsbhEFCxhRmfS6hLxe7BdLY6U6kIA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/compiler": "21.2.10",
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0"
},
"peerDependenciesMeta": {
"@angular/compiler": {
"optional": true
},
"zone.js": {
"optional": true
}
}
},
"node_modules/@angular/platform-browser": {
"version": "21.2.10",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.10.tgz",
"integrity": "sha512-5WMoHGU8BOV3eO9h3vGMIUDPf+3SHis7+X2dHKMtKfFBUtiO8m/lq2x3PzkkKj1782i7KYt92EqPHuADd/eWOw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/animations": "21.2.10",
"@angular/common": "21.2.10",
"@angular/core": "21.2.10"
},
"peerDependenciesMeta": {
"@angular/animations": {
"optional": true
}
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/zone.js": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
"license": "MIT"
}
}
}
@@ -0,0 +1,18 @@
{
"name": "serena-angular-test-repo",
"version": "0.0.0",
"private": true,
"description": "Minimal Angular workspace fixture used by Serena language-server tests.",
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.6.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"typescript": "~5.9.0"
}
}
@@ -0,0 +1,21 @@
<section>
<h1>{{ title() | exclaim }}</h1>
<p>{{ greeting() }}</p>
<input
type="text"
[value]="userName()"
(input)="setName($any($event.target).value)"
placeholder="Enter your name"
/>
@if (items().length > 0) {
<ul>
@for (item of items(); track item) {
<app-item-card [label]="item"></app-item-card>
}
</ul>
} @else {
<p>No items.</p>
}
</section>
@@ -0,0 +1,26 @@
import { Component, signal } from '@angular/core';
import { GreetingService } from './greeting.service';
import { ItemCardComponent } from './item-card.component';
import { ExclaimPipe } from './exclaim.pipe';
@Component({
selector: 'app-root',
standalone: true,
imports: [ItemCardComponent, ExclaimPipe],
templateUrl: './app.component.html',
})
export class AppComponent {
readonly title = signal('Serena Angular Test');
readonly userName = signal('');
readonly items = signal<string[]>(['alpha', 'beta', 'gamma']);
constructor(private readonly greetings: GreetingService) {}
greeting(): string {
return this.greetings.greet(this.userName() || undefined);
}
setName(name: string): void {
this.userName.set(name);
}
}
@@ -0,0 +1,6 @@
<!-- Template path: ngserver flags the reference to ``undefinedSignal()`` since
it has no matching field on DiagnosticsSampleComponent. The standalone .html
diagnostic exercises the Angular template compiler, not plain-HTML validation. -->
<section>
<h1>{{ undefinedSignal() }}</h1>
</section>
@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
// Two diagnostic sources in one fixture:
// * the type error on ``count`` is reported by tsserver via the Angular plugin
// when diagnosing this .ts file directly.
// * the ``undefinedSignal()`` reference in diagnostics_sample.html is reported
// by ngserver when diagnosing the template attached via ``templateUrl``.
@Component({
selector: 'app-diagnostics-sample',
standalone: true,
templateUrl: './diagnostics_sample.html',
})
export class DiagnosticsSampleComponent {
readonly count: number = 'not-a-number';
}
@@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'exclaim',
standalone: true,
})
export class ExclaimPipe implements PipeTransform {
transform(value: string, count: number = 1): string {
return value + '!'.repeat(count);
}
}
@@ -0,0 +1,3 @@
export interface Greeter {
greet(name?: string): string;
}
@@ -0,0 +1,11 @@
import { Injectable } from '@angular/core';
import { Greeter } from './greeter.interface';
@Injectable({ providedIn: 'root' })
export class GreetingService implements Greeter {
private readonly defaultName = 'World';
greet(name?: string): string {
return `Hello, ${name ?? this.defaultName}!`;
}
}
@@ -0,0 +1,18 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-item-card',
standalone: true,
template: `
<li class="item-card" [attr.data-label]="label">
<strong>{{ label }}</strong>
</li>
`,
})
export class ItemCardComponent {
@Input() label: string = '';
formatLabel(): string {
return this.label.trim();
}
}
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Serena Angular Test</title>
</head>
<body>
<app-root></app-root>
</body>
</html>
@@ -0,0 +1,4 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent).catch((err) => console.error(err));
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.html"]
}
@@ -0,0 +1,25 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>About — Serena HTML Test</title>
<link rel="stylesheet" href="styles/main.css">
</head>
<body>
<header id="page-header">
<h1 id="about-title">About</h1>
<nav id="main-nav">
<a href="index.html">Home</a>
</nav>
</header>
<main id="page-main">
<article id="about-article">
<h2>About this repo</h2>
<p>Used by the Serena language server tests.</p>
</article>
</main>
</body>
</html>
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<!-- Diagnostics smoke fixture — vscode-html-language-server does not emit
diagnostics for HTML structure; this file just gives the LS something to
chew on so the diagnostics endpoint round-trip can be exercised. -->
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Diagnostics Sample</title>
</head>
<body>
<div id="dup">first</div>
<div id="dup">second</div>
</body>
</html>
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Serena HTML Test</title>
<link rel="stylesheet" href="styles/main.css">
</head>
<body>
<header id="page-header">
<h1 id="site-title">Serena HTML Test Repo</h1>
<nav id="main-nav">
<a href="about.html">About</a>
<a href="#section-features">Features</a>
</nav>
</header>
<main id="page-main">
<section id="section-intro">
<h2>Introduction</h2>
<p>A minimal HTML test fixture used by the Serena language-server tests.</p>
</section>
<section id="section-features">
<h2>Features</h2>
<ul id="feature-list">
<li>Symbol detection</li>
<li>Element navigation</li>
<li>Cross-file links</li>
</ul>
</section>
</main>
<footer id="page-footer">
<p>&copy; Serena</p>
</footer>
<script src="scripts/app.js"></script>
</body>
</html>
+18 -1
View File
@@ -11,8 +11,18 @@
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.plugin.version>3.13.0</maven.compiler.plugin.version>
<lombok.version>1.18.38</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
@@ -22,6 +32,13 @@
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
@@ -0,0 +1,13 @@
package test_repo;
class DefaultGreeter implements LombokModel.Greeter {
@Override
public String greet() {
return "hi";
}
@Override
public String farewell() {
return "bye";
}
}
@@ -0,0 +1,14 @@
package test_repo;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
/** Verifies prefix-stripped fluent accessors generated by @Accessors(fluent=true). */
@Getter
@Setter
@Accessors(fluent = true)
public class FluentLombokModel {
private String host;
private String tag;
}
@@ -0,0 +1,27 @@
package test_repo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.With;
import lombok.experimental.Delegate;
@Data
@Builder(toBuilder = true)
@With
@AllArgsConstructor
@NoArgsConstructor
public class LombokModel {
private String name;
private int age;
/** Lombok @Delegate forwards every method of Greeter to delegate.* (covers @Delegate generation). */
public interface Greeter {
String greet();
String farewell();
}
@Delegate
private final Greeter delegate = new DefaultGreeter();
}
@@ -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,14 @@
export function sharedUtilityFunction(input: string): string {
return `processed: ${input}`;
}
export class SharedClass {
name: string;
constructor(name: string) {
this.name = name;
}
greet(): string {
return `Hello, ${this.name}`;
}
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"declaration": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts"]
}
@@ -0,0 +1,10 @@
import { sharedUtilityFunction, SharedClass } from "../cross_package_a/shared_utils";
export function consumeSharedUtil(): string {
return sharedUtilityFunction("test data");
}
export function consumeSharedClass(): string {
const instance = new SharedClass("World");
return instance.greet();
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"rootDir": ".",
"paths": {
"../cross_package_a/*": ["../cross_package_a/*"]
}
},
"include": ["*.ts"]
}
+88
View File
@@ -0,0 +1,88 @@
"""
Pytest fixtures for Angular language server tests.
This conftest is NOT installing the language server itself — that is fully handled
by ``RuntimeDependencyCollection`` inside ``AngularLanguageServer`` and lands in
Serena's managed ``ls_resources_dir`` like every other LS download.
What we install here is the *test fixture project's* npm dependencies (notably
``@angular/core``) into the fixture's own ``node_modules``. ngserver requires
``@angular/core`` to be resolvable from the workspace root or it silently treats
every file as "not in an Angular project", at which point all template-aware
features (definition / hover / references on .html, cross-file template→component
navigation) return empty results — tests would pass vacuously while exercising
nothing. Vendoring ``node_modules`` in-repo is impractical (~500MB), so we run
``npm install`` once per checkout, cached across sessions, and serialised across
xdist workers via ``filelock``.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from filelock import FileLock
log = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parents[2] / "resources" / "repos" / "angular" / "test_repo"
NODE_MODULES = REPO_ROOT / "node_modules"
ANGULAR_CORE_MARKER = NODE_MODULES / "@angular" / "core" / "package.json"
# Lock file lives inside the repo (covered by its .gitignore) so xdist workers
# can serialise on the install. Putting it elsewhere risks placing it on a
# different filesystem from the install dir, which makes flock semantics fuzzy.
INSTALL_LOCK = REPO_ROOT / ".angular-install.lock"
@pytest.fixture(scope="session", autouse=True)
def _install_angular_test_repo_node_modules() -> None:
"""
Populate the *test fixture project's* ``node_modules`` (NOT the language server's
install; see module docstring for the distinction).
Cached across sessions: once ``node_modules/@angular/core/package.json`` exists,
this is a no-op. Skipped (with the test session marked) if npm is unavailable.
Under pytest-xdist multiple workers race on the install. ``npm install`` does
not internally serialize concurrent runs against the same install dir, so we
take an exclusive ``filelock`` around the whole install/check sequence — the
second worker into the lock will then short-circuit on the marker check.
"""
if ANGULAR_CORE_MARKER.exists():
log.info("Angular test repo node_modules already populated; skipping npm install")
return
if shutil.which("npm") is None:
pytest.skip("npm is not available; cannot install Angular test repo dependencies")
with FileLock(str(INSTALL_LOCK)):
# Re-check inside the lock: a sibling worker may have just installed.
if ANGULAR_CORE_MARKER.exists():
log.info("Angular test repo node_modules populated by another worker; skipping npm install")
return
log.warning(
"Installing npm dependencies into the Angular test repo at %s. This is a one-time cost per checkout and may take ~30s.",
REPO_ROOT,
)
proc = subprocess.run(
["npm", "install", "--no-audit", "--no-fund", "--loglevel=warn"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
if proc.returncode != 0:
log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr)
pytest.skip(f"npm install failed in {REPO_ROOT} (rc={proc.returncode}); see logs for details")
if not ANGULAR_CORE_MARKER.exists():
pytest.skip(f"npm install completed but {ANGULAR_CORE_MARKER} is missing; cannot run Angular tests")
log.info("Angular test repo node_modules installed successfully")
+344
View File
@@ -0,0 +1,344 @@
"""
Basic integration tests for the Angular language server.
The Angular LS understands Angular template syntax (*ngIf, [prop], (event),
{{ interpolation }}, @if/@for blocks) and provides type-aware navigation
between templates and component classes.
Workspace: a minimal standalone-component Angular app with a service that the
component injects, plus a template (.html) that interpolates component methods.
"""
import os
import re
from pathlib import Path
import pytest
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import read_repo_file, request_all_symbols
@pytest.mark.angular
class TestAngularLanguageServerBasics:
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.ANGULAR], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_symbols(self, language_server: SolidLanguageServer) -> None:
"""The Angular LS exposes the component class methods/fields via tsserver."""
all_symbols, _ = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("AppComponent", "title", "userName", "items", "greeting", "setName"):
assert expected in names, f"Expected '{expected}' in component symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_service_class_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("src/app/greeting.service.ts").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("GreetingService", "greet", "defaultName"):
assert expected in names, f"Expected '{expected}' in service symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None:
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
# The HTML companion is unconditionally started, so .html documentSymbol
# contributes structural element entries to the workspace tree alongside
# the two TS files.
# relativePath uses OS-native separators (Path.relative_to), so build expectations the same way.
for f in (
os.path.join("src", "app", "app.component.ts"),
os.path.join("src", "app", "greeting.service.ts"),
os.path.join("src", "app", "app.component.html"),
):
assert f in relative_paths, f"Expected {f} to appear in symbol tree, got {relative_paths}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_definition_to_component_method(self, language_server: SolidLanguageServer) -> None:
"""Resolve `greeting()` interpolation in the template to its component method.
Cursor lands inside the ``greeting`` identifier on the ``{{ greeting() }}``
line of ``app.component.html``.
"""
path = "src/app/app.component.html"
needle = "greeting()"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# +1 puts the cursor inside the identifier rather than on its leading boundary.
definitions = language_server.request_definition(path, line, col + 1)
assert definitions, f"Expected non-empty cross-file definition for template->component method, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_plain_html_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""DocumentSymbol on plain index.html should come back from the HTML companion.
ngserver returns -32601 for documentSymbol on every .html file (plain or
Angular template). The AngularLanguageServer routes .html documentSymbol
to a tertiary vscode-html-language-server companion so users get a
structural element outline instead of an empty list.
"""
all_symbols, _ = language_server.request_document_symbols("src/index.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
# index.html contains <html>, <head>, <meta>, <title>, <body>, <app-root>
for expected in ("html", "head", "body", "app-root"):
assert expected in names, f"Expected '{expected}' in plain-HTML symbol list, got: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_html_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""DocumentSymbol on an Angular template must return the HTML element tree.
Angular template syntax (``@if``/``@for``/``{{ }}``) is ignored by the
vscode-html-language-server parser but the surrounding element structure
(``<section>``, ``<h1>``, ``<p>``, ``<input>``, ``<ul>``, ``<app-item-card>``)
is still reported, which is the intended outline.
"""
all_symbols, _ = language_server.request_document_symbols("src/app/app.component.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("section", "h1", "p", "input", "ul", "app-item-card"):
assert expected in names, f"Expected '{expected}' in template HTML symbol list, got: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_ts_method_references_include_template_usage(self, language_server: SolidLanguageServer) -> None:
"""References on a .ts component method must include its template callers.
``setName`` is defined in app.component.ts and bound in the template via
``(input)="setName(...)"``. On real Angular projects the typescript-language-server
alone misses the template callers, so the Angular LS routes .ts references
through ngserver which aggregates both .ts and .html usages.
"""
src_path = "src/app/app.component.ts"
# Probe at the declaration site of setName, not a usage; the regex anchors on the
# parameter list signature so we always land on the method header even if calls move.
coords = find_text_coordinates(read_repo_file(language_server, src_path), r"(setName)\(name:")
assert coords is not None, "Could not locate setName declaration in app.component.ts"
# +1 puts the cursor inside the identifier rather than on its leading boundary.
refs = language_server.request_references(src_path, coords.line, coords.col + 1)
ref_paths = {r.get("relativePath", "") for r in refs}
assert any(p.endswith("app.component.html") for p in ref_paths), (
f"Expected references for setName to include its template callsite in app.component.html, got: {ref_paths}"
)
@pytest.mark.angular
class TestAngularHover:
"""Hover routing — .ts goes to the companion tsserver, .html goes to ngserver."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_hover_on_ts_method(self, language_server: SolidLanguageServer) -> None:
"""Hover on a .ts method declaration is routed through the companion TS server
and must yield a non-empty MarkupContent describing the method signature.
"""
path = "src/app/app.component.ts"
needle = "setName("
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past the leading 's') so the LSP
# treats the position as the symbol rather than a token boundary.
hover = language_server.request_hover(path, line, col + 1)
assert hover is not None, f"Expected hover info for setName in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "setName" in text, f"Expected setName in hover text, got: {text}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_hover_on_template_method_call(self, language_server: SolidLanguageServer) -> None:
"""Hover on a method call inside an Angular template ({{ greeting() }}) goes
through ngserver and must yield Angular-aware type info.
"""
path = "src/app/app.component.html"
needle = "greeting()"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past the leading 'g').
hover = language_server.request_hover(path, line, col + 1)
assert hover is not None, f"Expected hover info for greeting() in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
@pytest.mark.angular
class TestAngularDefinitionRouting:
"""Cross-file definition for the binding flavours not covered by basic tests."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_property_binding(self, language_server: SolidLanguageServer) -> None:
"""Property binding ``[value]="userName()"`` must resolve to the component's
``userName`` signal field declaration in app.component.ts.
"""
path = "src/app/app.component.html"
needle = '[value]="userName()"'
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor on the 'u' of userName inside the binding expression
col = col + len('[value]="')
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected non-empty definition for userName binding, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_event_binding(self, language_server: SolidLanguageServer) -> None:
"""Event binding ``(input)="setName(...)"`` must resolve to the component's
``setName`` method declaration in app.component.ts.
"""
path = "src/app/app.component.html"
needle = '(input)="setName('
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + len('(input)="') # cursor on 's' of setName
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected non-empty definition for setName binding, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_service_import_in_component(self, language_server: SolidLanguageServer) -> None:
"""The ``GreetingService`` symbol used in the constructor parameter list
of AppComponent must resolve to greeting.service.ts via the companion TS server.
"""
path = "src/app/app.component.ts"
# Probe at the constructor signature, not the import line, to make sure
# we exercise the type-resolution path rather than the module-resolution path.
needle = "private readonly greetings: GreetingService"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + len("private readonly greetings: ") # cursor on 'G' of GreetingService
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected definition for GreetingService, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("greeting.service.ts") for uri in target_uris), (
f"Expected definition to resolve into greeting.service.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_child_component_selector(self, language_server: SolidLanguageServer) -> None:
"""The ``<app-item-card>`` element in the parent template must resolve to the
ItemCardComponent class declaration in item-card.component.ts.
This is an Angular-specific feature: the @angular/language-service tsserver
plugin makes the selector string a navigable type-reference back to its
component class.
"""
path = "src/app/app.component.html"
needle = "app-item-card"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + 1 # cursor inside 'app-item-card'
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected definition for <app-item-card>, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("item-card.component.ts") for uri in target_uris), (
f"Expected definition to resolve into item-card.component.ts, got URIs: {target_uris}"
)
@pytest.mark.angular
class TestAngularRename:
"""Rename routing returns a WorkspaceEdit without applying it (safe for fixtures)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_rename_method_returns_edits_for_ts_and_template(self, language_server: SolidLanguageServer) -> None:
"""Renaming the ``setName`` method from its .ts declaration must return a
WorkspaceEdit that touches both app.component.ts (declaration + any TS calls)
and app.component.html (the ``(input)="setName(...)"`` binding).
"""
path = "src/app/app.component.ts"
# Anchor on the declaration site (signature with the parameter list), not a usage.
coords = find_text_coordinates(read_repo_file(language_server, path), r"(setName)\(name:")
assert coords is not None, "Could not locate setName declaration in app.component.ts"
# +1 puts the cursor inside the identifier rather than on its leading boundary.
edit = language_server.request_rename_symbol_edit(path, coords.line, coords.col + 1, "updateName")
assert edit is not None, "Expected WorkspaceEdit, got None"
changes = edit.get("changes") or {}
# Some servers return ``documentChanges`` instead of (or in addition to) ``changes``.
document_changes = edit.get("documentChanges") or []
all_uris: set[str] = set(changes.keys())
for dc in document_changes:
uri = (dc.get("textDocument") or {}).get("uri") or dc.get("uri")
if uri:
all_uris.add(uri)
assert any(uri.endswith("app.component.ts") for uri in all_uris), f"Expected rename edits in app.component.ts, got URIs: {all_uris}"
assert any(uri.endswith("app.component.html") for uri in all_uris), (
f"Expected rename edits in app.component.html (template binding), got URIs: {all_uris}"
)
@pytest.mark.angular
class TestAngularSymbolStructure:
"""Hierarchical symbol structure — class symbols must contain method/field children."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_has_methods_and_fields_as_children(self, language_server: SolidLanguageServer) -> None:
"""``request_document_symbols`` should return AppComponent as a class symbol
whose children include its methods (``greeting``, ``setName``) and signal
fields (``title``, ``userName``, ``items``). Flat-name checks pass even when
the hierarchy is broken; this test asserts the parent-child relationship.
"""
all_symbols, root_symbols = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots()
component = next((s for s in all_symbols if s.get("name") == "AppComponent"), None)
assert component is not None, "AppComponent class symbol not found"
children = component.get("children") or []
child_names = {c.get("name") for c in children}
for expected in ("greeting", "setName", "title", "userName", "items"):
assert expected in child_names, f"Expected '{expected}' as child of AppComponent, got: {child_names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_pipe_class_in_symbol_tree(self, language_server: SolidLanguageServer) -> None:
"""A custom pipe (``ExclaimPipe``) declared in exclaim.pipe.ts must appear in
the workspace symbol tree alongside the components and the service.
"""
all_symbols = request_all_symbols(language_server)
names = {s.get("name") for s in all_symbols}
for expected in ("ExclaimPipe", "ItemCardComponent", "AppComponent", "GreetingService"):
assert expected in names, f"Expected '{expected}' in full symbol tree, got: {sorted(names)}"
@pytest.mark.angular
class TestAngularImplementations:
"""``textDocument/implementation`` is routed through tsserver via the Angular plugin."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_find_implementations_of_interface_method(self, language_server: SolidLanguageServer) -> None:
"""``Greeter.greet`` is implemented by ``GreetingService``; ``request_implementation``
invoked at the interface declaration must point at the service.
Routed through the Angular LS' companion typescript-language-server (with the
@angular/language-service plugin loaded as a tsserver plugin) — bare ngserver
does not own ``textDocument/implementation``.
"""
path = "src/app/greeter.interface.ts"
needle = "greet("
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past 'g').
implementations = language_server.request_implementation(path, line, col + 1)
assert implementations, f"Expected at least one implementation of Greeter.greet, got {implementations}"
target_uris = [impl["uri"] for impl in implementations]
assert any(uri.endswith("greeting.service.ts") for uri in target_uris), (
f"Expected implementation to resolve into greeting.service.ts, got URIs: {target_uris}"
)
@@ -0,0 +1,44 @@
"""
Diagnostics tests for the Angular language server.
Two paths are exercised:
* .ts component — tsserver (via the @angular/language-service plugin) reports
the type-mismatch on a class field initializer.
* .html template — ngserver reports the unresolved identifier in a template
interpolation, but only because the template is attached to a @Component via
``templateUrl``. Bare .html files are not type-checked by ngserver.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.angular
class TestAngularDiagnostics:
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""The component's ``count: number = 'not-a-number'`` must be flagged by tsserver."""
assert_file_diagnostics(
language_server,
"src/app/diagnostics_sample.ts",
(),
min_count=1,
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""The template's ``{{ undefinedSignal() }}`` must be flagged by ngserver.
Routed through the Angular template compiler, which only checks templates
attached to a @Component — see the companion ``diagnostics_sample.ts`` that
wires this file via ``templateUrl``.
"""
assert_file_diagnostics(
language_server,
"src/app/diagnostics_sample.html",
(),
min_count=1,
)
@@ -0,0 +1,369 @@
"""
Error / edge-case tests for the Angular language server.
The Angular LS routes by file extension across three child processes:
* ``ngserver`` for ``.html`` definition / references / hover
* companion ``typescript-language-server`` (with @angular/language-service
loaded as a tsserver plugin) for ``.ts`` operations
* companion ``vscode-html-language-server`` for ``.html`` documentSymbol
The behaviour locked in below is **observed** behaviour on Linux with the
versions pinned in ``angular_language_server.py`` (Angular LS 21.2.10,
typescript-language-server 5.1.3, TypeScript 5.9.3). It is platform-aware where
upstream LSPs are known to differ (Windows TS server tends to swallow malformed
positions instead of raising).
"""
import os
import sys
import time
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_exceptions import SolidLSPException
from test.conftest import _create_ls
pytestmark = pytest.mark.angular
IS_WINDOWS = sys.platform == "win32"
TS_FILE = os.path.join("src", "app", "app.component.ts")
SERVICE_FILE = os.path.join("src", "app", "greeting.service.ts")
TEMPLATE_FILE = os.path.join("src", "app", "app.component.html")
class TestAngularInvalidPositionsOnTs:
"""Negative / out-of-bounds line and column on .ts files (TS-companion route)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_negative_line_number_containing_symbol(self, language_server: SolidLanguageServer) -> None:
"""``request_containing_symbol`` short-circuits before reaching the LS:
a negative line returns None.
"""
result = language_server.request_containing_symbol(TS_FILE, -1, 0)
assert result is None, f"Expected None for negative line, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_negative_character_number_containing_symbol(self, language_server: SolidLanguageServer) -> None:
result = language_server.request_containing_symbol(TS_FILE, 5, -1)
assert result is None, f"Expected None for negative character, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None:
"""The wrapper code raises ``IndexError`` before reaching the LS."""
with pytest.raises(IndexError) as exc_info:
language_server.request_containing_symbol(TS_FILE, 99999, 0)
assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_character_beyond_line_length_returns_enclosing_class(self, language_server: SolidLanguageServer) -> None:
"""Character far beyond end-of-line is clamped by the LSP. Line 5 of
app.component.ts is inside ``export class AppComponent { ... }``, so the
TS companion (with the Angular plugin) returns the AppComponent class
as the containing symbol — not None — which is the documented LSP
behaviour.
"""
result = language_server.request_containing_symbol(TS_FILE, 5, 99999)
assert isinstance(result, dict), f"Expected dict (containing class), got: {result!r}"
assert result.get("name") == "AppComponent", f"Expected containing symbol 'AppComponent', got: {result.get('name')!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_references_at_negative_line_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""The Angular LS's TS companion returns ``[]`` for negative-line
``request_references`` — it does **not** raise. (Plain Vue tests
observe a ``Bad line number`` raise on Linux/macOS; the Angular
plugin layered on tsserver suppresses this.)
"""
result = language_server.request_references(TS_FILE, -1, 0)
assert result == [], f"Expected [], got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_at_negative_position_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""Same as references: TS companion returns ``[]``, does not raise."""
result = language_server.request_definition(TS_FILE, -1, 0)
assert result == [], f"Expected [], got: {result!r}"
@pytest.mark.skipif(
IS_WINDOWS,
reason="Windows ngserver swallows malformed positions instead of raising — same divergence as the TS companion (see test_referencing_symbols_at_invalid_position_raises).",
)
class TestAngularInvalidPositionsOnTemplate:
"""Negative / out-of-bounds positions on .html files (ngserver route)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_negative_line_definition_raises(self, language_server: SolidLanguageServer) -> None:
"""Ngserver does **not** swallow malformed positions: it surfaces a
``Debug Failure. False expression.`` error from its underlying
compiler. Lock that behaviour to catch any future change.
"""
with pytest.raises(SolidLSPException) as exc_info:
language_server.request_definition(TEMPLATE_FILE, -1, 0)
assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), (
f"Unexpected exception message: {exc_info.value}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_character_beyond_line_definition_raises(self, language_server: SolidLanguageServer) -> None:
"""Same path as negative line — ngserver raises Debug Failure."""
with pytest.raises(SolidLSPException) as exc_info:
language_server.request_definition(TEMPLATE_FILE, 0, 99999)
assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), (
f"Unexpected exception message: {exc_info.value}"
)
class TestAngularNonExistentFiles:
"""Requests against files that don't exist must raise FileNotFoundError consistently."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_nonexistent_ts_file_raises(self, language_server: SolidLanguageServer) -> None:
nonexistent = os.path.join("src", "app", "does-not-exist.component.ts")
with pytest.raises(FileNotFoundError):
language_server.request_references(nonexistent, 0, 0)
with pytest.raises(FileNotFoundError):
language_server.request_definition(nonexistent, 0, 0)
with pytest.raises(FileNotFoundError):
language_server.request_document_symbols(nonexistent)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_nonexistent_html_file_raises(self, language_server: SolidLanguageServer) -> None:
nonexistent = os.path.join("src", "app", "does-not-exist.component.html")
with pytest.raises(FileNotFoundError):
language_server.request_definition(nonexistent, 0, 0)
with pytest.raises(FileNotFoundError):
language_server.request_document_symbols(nonexistent)
class TestAngularUndefinedSymbols:
"""Symbols that have no callers / definitions / referencing positions."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_at_keyword_position_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""Cursor on the ``import`` keyword (line 0, col 0 of app.component.ts)
has no definition target — the TS companion returns ``[]``.
"""
result = language_server.request_definition(TS_FILE, 0, 0)
assert result == [], f"Expected [] for keyword position, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_references_for_local_const_have_few_callers(self, language_server: SolidLanguageServer) -> None:
"""A locally-scoped private field has at most its declaration plus its
in-method use as references. ``defaultName`` on GreetingService is
used only inside ``greet()``.
"""
all_symbols, _ = language_server.request_document_symbols(SERVICE_FILE).get_all_symbols_and_roots()
sym = next((s for s in all_symbols if s.get("name") == "defaultName"), None)
assert sym is not None, "defaultName symbol missing in fixture"
sel = sym["selectionRange"]["start"]
refs = language_server.request_references(SERVICE_FILE, sel["line"], sel["character"])
assert isinstance(refs, list), f"Expected list, got {type(refs)}"
# 1 declaration + 1 internal read = 2 expected; allow 1-3 to absorb
# whether the LSP includes the declaration.
assert 1 <= len(refs) <= 3, f"defaultName should have 1-3 references, got {len(refs)}: {refs}"
class TestAngularEdgeCasePositions:
"""Position (0,0), whitespace lines, and other boundary conditions."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_containing_symbol_at_file_start_is_none(self, language_server: SolidLanguageServer) -> None:
"""Line 0 of app.component.ts is an ``import`` statement, outside any
class or function — the TS companion returns ``None`` for containing
symbol.
"""
result = language_server.request_containing_symbol(TS_FILE, 0, 0)
assert result is None, f"Expected None at (0,0), got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_references_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""Position (0, 0) is on the ``import`` keyword — no references."""
result = language_server.request_references(TS_FILE, 0, 0)
assert result == [], f"Expected [] at (0,0), got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None:
"""Position (0, 0) is on the ``import`` keyword — no definition."""
result = language_server.request_definition(TS_FILE, 0, 0)
assert result == [], f"Expected [] at (0,0), got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_position_no_containing_symbol(self, language_server: SolidLanguageServer) -> None:
"""An Angular template has no class/function containers; the HTML
companion's documentSymbol provides element symbols only.
"""
# (1, 4) lands inside <h1>{{ title() | exclaim }}</h1> on line 1.
result = language_server.request_containing_symbol(TEMPLATE_FILE, 1, 4)
assert result is None, f"Expected None inside template, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_containing_symbol_inside_class_body_returns_class(self, language_server: SolidLanguageServer) -> None:
"""Lines 5 and 10 of app.component.ts are inside the AppComponent class
body. The TS companion correctly reports AppComponent as the
containing symbol.
"""
for line in (5, 10):
result = language_server.request_containing_symbol(TS_FILE, line, 0)
assert isinstance(result, dict), f"Line {line}: expected dict, got {result!r}"
assert result.get("name") == "AppComponent", f"Line {line}: expected 'AppComponent', got {result.get('name')!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_containing_symbol_outside_class_body_is_none(self, language_server: SolidLanguageServer) -> None:
"""Line 0 (import) and line 15 (blank/whitespace inside file but
outside any symbol's range, depending on file structure) report no
containing symbol.
"""
for line in (0,):
result = language_server.request_containing_symbol(TS_FILE, line, 0)
assert result is None, f"Line {line}: expected None, got {result!r}"
class TestAngularReferenceEdgeCases:
"""Edge cases for the SolidLanguageServer reference helpers (built on top of LSP)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_referencing_symbols_at_invalid_position_raises(self, language_server: SolidLanguageServer) -> None:
"""Unlike ``request_references`` (which returns ``[]``),
``request_referencing_symbols`` validates more strictly and surfaces
the underlying TS server error as a ``SolidLSPException``.
"""
if IS_WINDOWS:
# Windows TS server is known to swallow these — keep platform-specific
# contract minimal here since we can't run Windows in dev.
result = list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False))
assert result == [], f"Expected [] on Windows, got: {result!r}"
return
with pytest.raises(SolidLSPException) as exc_info:
list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False))
assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value), (
f"Unexpected exception message: {exc_info.value}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_defining_symbol_at_invalid_position_returns_none(self, language_server: SolidLanguageServer) -> None:
"""``request_defining_symbol`` short-circuits when no definition is
found and returns None — no exception, even for negative positions.
"""
result = language_server.request_defining_symbol(TS_FILE, -1, -1)
assert result is None, f"Expected None, got: {result!r}"
class TestAngularCoreProbe:
"""Unit tests for the monorepo-aware ``_find_angular_core_install`` walk.
These don't spin up the Angular LS — they exercise the pure-Python
filesystem walker against synthetic node_modules layouts.
"""
@staticmethod
def _walk(repo_path: str) -> str | None:
"""Invoke the bound method against a minimal stub carrying only
``repository_root_path``.
"""
from solidlsp.language_servers.angular_language_server import AngularLanguageServer
stub = type("Stub", (), {"repository_root_path": repo_path})()
return AngularLanguageServer._find_angular_core_install(stub) # type: ignore[arg-type]
def test_finds_core_at_project_root(self, tmp_path) -> None:
core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
core_pkg.parent.mkdir(parents=True)
core_pkg.write_text("{}")
assert self._walk(str(tmp_path)) == str(core_pkg)
def test_finds_hoisted_core_in_workspace_parent(self, tmp_path) -> None:
"""Nx / yarn-workspaces layout: ``node_modules`` is hoisted to the
workspace root and the activated sub-package has none of its own.
"""
sub_pkg = tmp_path / "packages" / "app"
sub_pkg.mkdir(parents=True)
core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
core_pkg.parent.mkdir(parents=True)
core_pkg.write_text("{}")
# Workspace root marker — the walker stops here.
(tmp_path / "package.json").write_text('{"workspaces": ["packages/*"]}')
assert self._walk(str(sub_pkg)) == str(core_pkg)
def test_returns_none_when_not_installed(self, tmp_path) -> None:
"""No node_modules anywhere — walker exhausts and returns None."""
sub_pkg = tmp_path / "packages" / "app"
sub_pkg.mkdir(parents=True)
assert self._walk(str(sub_pkg)) is None
def test_workspace_root_stops_walk(self, tmp_path) -> None:
"""If a ``package.json`` declares ``workspaces`` we should not walk past
it — the workspace root is the canonical install location even when no
``@angular/core`` is present there.
"""
# Plant a misleading sibling install ABOVE the workspace root that the
# walker must NOT find.
sibling_core = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
sibling_core.parent.mkdir(parents=True)
sibling_core.write_text("{}")
workspace = tmp_path / "ws"
workspace.mkdir()
(workspace / "package.json").write_text('{"workspaces": ["packages/*"]}')
sub_pkg = workspace / "packages" / "app"
sub_pkg.mkdir(parents=True)
assert self._walk(str(sub_pkg)) is None
class TestAngularStartupCleanup:
"""Regression: companion processes (TS + HTML) must not leak when ngserver
fails partway through ``_start_server``.
Until the cleanup wrapper landed, an exception during ngserver init left
the two companion Node processes orphaned because the parent constructor
never returned a handle on which the caller could invoke ``stop()``.
"""
def test_companions_cleaned_up_on_initialize_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
if IS_WINDOWS:
pytest.skip("psutil child enumeration is flaky under Windows CI")
import psutil
ls = _create_ls(Language.ANGULAR)
my_proc = psutil.Process(os.getpid())
children_before = {p.pid for p in my_proc.children(recursive=True)}
def boom(*_args: object, **_kwargs: object) -> dict:
raise RuntimeError("simulated ngserver init failure")
# _get_initialize_params runs after both companions and ngserver have
# been spawned — exactly the failure window the cleanup wrapper
# protects against.
monkeypatch.setattr(ls, "_get_initialize_params", boom)
try:
with pytest.raises(RuntimeError, match="simulated ngserver init"):
ls.start()
assert ls._ts_server is None, "TS companion was not cleared after startup failure"
assert ls._html_server is None, "HTML companion was not cleared after startup failure"
# Allow a brief grace period for the OS to reap the spawned Node
# processes (and grandchildren — typescript-language-server forks
# tsserver).
deadline = time.monotonic() + 5.0
new_children: set[int] = set()
while time.monotonic() < deadline:
children_after = {p.pid for p in my_proc.children(recursive=True)}
new_children = children_after - children_before
if not new_children:
break
time.sleep(0.2)
assert not new_children, f"Companion processes leaked after startup failure: {sorted(new_children)}"
finally:
# Best-effort: if anything is still around (e.g. ngserver itself,
# which lives outside the cleanup contract), shut it down so the
# test session doesn't leave orphans.
try:
ls.stop(shutdown_timeout=2.0)
except Exception:
pass
+12
View File
@@ -1,3 +1,5 @@
from pathlib import Path
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
@@ -5,6 +7,16 @@ from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY]
def read_repo_file(language_server: SolidLanguageServer, relative_path: str) -> str:
"""Read the text content of ``relative_path`` resolved against the LS's repository root.
Convenience for test code that needs to feed file content to
:func:`serena.util.text_utils.find_text_coordinates`.
"""
abs_path = Path(language_server.language_server.repository_root_path) / relative_path
return abs_path.read_text()
def is_diagnostics_test_file(relative_path: str) -> bool:
normalized_path = relative_path.replace("\\", "/")
filename = normalized_path.rsplit("/", 1)[-1].lower()
+1 -64
View File
@@ -2,22 +2,17 @@ import os
import tempfile
from pathlib import Path
from typing import cast
from unittest.mock import Mock, patch
import pytest
from sensai.util import logging
from serena.util.logging import SuspendedLoggersContext
from solidlsp import SolidLanguageServer
from solidlsp.language_servers.csharp_language_server import (
CSharpLanguageServer,
breadth_first_file_scan,
find_solution_or_project_file,
)
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import SymbolUtils
from solidlsp.settings import SolidLSPSettings
from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@@ -314,64 +309,6 @@ class TestCSharpSolutionProjectOpening:
# Should still prefer .sln file even though it's deeper
assert result == str(solution_file)
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_server_installed")
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server")
def test_csharp_language_server_logs_solution_discovery(self, mock_start_server, mock_ensure_server_installed):
"""Test that CSharpLanguageServer logs solution/project discovery during initialization."""
mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll")
# Create test directory with solution file
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
solution_file = temp_path / "TestSolution.sln"
solution_file.touch()
mock_config = Mock(spec=LanguageServerConfig)
mock_config.ignored_paths = []
# Create CSharpLanguageServer instance
mock_settings = Mock(spec=SolidLSPSettings)
mock_settings.ls_resources_dir = "/tmp/test_ls_resources"
mock_settings.project_data_path = str(temp_path / "project_data")
with SuspendedLoggersContext():
logging.getLogger().setLevel(logging.DEBUG)
with logging.MemoryLoggerContext() as mem_log:
CSharpLanguageServer(mock_config, str(temp_path), mock_settings)
# Verify that logger was called with solution file discovery
expected_log_msg = f"Found solution/project file: {solution_file}"
assert expected_log_msg in mem_log.get_log()
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_server_installed")
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server")
def test_csharp_language_server_logs_no_solution_warning(self, mock_start_server, mock_ensure_server_installed):
"""Test that CSharpLanguageServer logs warning when no solution/project files are found."""
# Mock the server installation
mock_ensure_server_installed.return_value = ("/usr/bin/dotnet", "/path/to/server.dll")
# Create empty test directory
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Mock logger to capture log messages
mock_config = Mock(spec=LanguageServerConfig)
mock_config.ignored_paths = []
mock_settings = Mock(spec=SolidLSPSettings)
mock_settings.ls_resources_dir = "/tmp/test_ls_resources"
mock_settings.project_data_path = str(temp_path / "project_data")
# Create CSharpLanguageServer instance
with SuspendedLoggersContext():
logging.getLogger().setLevel(logging.DEBUG)
with logging.MemoryLoggerContext() as mem_log:
CSharpLanguageServer(mock_config, str(temp_path), mock_settings)
# Verify that logger was called with warning about no solution/project files
expected_log_msg = "No .sln/.slnx or .csproj file found, language server will attempt auto-discovery"
assert expected_log_msg in mem_log.get_log()
def test_solution_and_project_opening_with_real_test_repo(self):
"""Test solution and project opening with the actual C# test repository."""
# Get the C# test repo path
-104
View File
@@ -1,104 +0,0 @@
"""Unit tests for Elixir Expert language server startup behavior."""
import threading
from unittest.mock import MagicMock
from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools
def _make_elixir_tools(tmp_path):
language_server = object.__new__(ElixirTools)
language_server.repository_root_path = str(tmp_path)
language_server.server_ready = threading.Event()
language_server.request_id = 0
return language_server
def _make_mock_server():
server = MagicMock()
server.send.initialize.return_value = {"capabilities": {"textDocumentSync": {}}}
return server
class TestElixirToolsStartup:
"""Tests for ElixirTools._start_server() startup behavior — the Expert deadlock fix."""
def test_sends_did_open_for_mix_exs_after_initialized(self, tmp_path):
"""Expert blocks waiting for a didOpen notification to trigger its build pipeline."""
(tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8")
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
language_server._start_server()
calls = server.notify.did_open_text_document.call_args_list
assert len(calls) == 1, f"Expected one didOpen notification for mix.exs, got {len(calls)}"
text_doc = calls[0][0][0]["textDocument"]
assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}"
assert text_doc["languageId"] == "elixir"
def test_did_open_contains_mix_exs_content(self, tmp_path):
"""The didOpen notification must include the file content so Expert can parse it."""
content = "defmodule App.MixProject do\n use Mix.Project\nend\n"
(tmp_path / "mix.exs").write_text(content, encoding="utf-8")
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
language_server._start_server()
calls = server.notify.did_open_text_document.call_args_list
assert len(calls) == 1
text_doc = calls[0][0][0]["textDocument"]
assert text_doc["text"] == content
def test_closes_mix_exs_after_server_ready(self, tmp_path):
"""mix.exs is closed after Expert signals readiness so it does not linger open."""
(tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8")
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
language_server._start_server()
close_calls = server.notify.did_close_text_document.call_args_list
assert len(close_calls) == 1, f"Expected one didClose for mix.exs, got {len(close_calls)}"
text_doc = close_calls[0][0][0]["textDocument"]
assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}"
def test_startup_event_order_initialized_then_didopen_then_didclose(self, tmp_path):
"""Initialized → didOpen(mix.exs) → [ready] → didClose(mix.exs) is the required sequence."""
(tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8")
events: list[str] = []
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
server.notify.initialized.side_effect = lambda *_: events.append("initialized")
server.notify.did_open_text_document.side_effect = lambda *_: events.append("didOpen")
server.notify.did_close_text_document.side_effect = lambda *_: events.append("didClose")
language_server._start_server()
assert events == ["initialized", "didOpen", "didClose"], f"Unexpected startup event order: {events}"
def test_no_crash_when_mix_exs_missing(self, tmp_path):
"""_start_server must not crash if mix.exs is absent (non-standard project layout)."""
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
language_server._start_server()
server.notify.did_open_text_document.assert_not_called()
View File
Whitespace-only changes.
+54
View File
@@ -0,0 +1,54 @@
"""
Basic integration tests for the HTML language server.
The HTML LSP (vscode-html-language-server) provides in-file document symbols
based on the element tree. Cross-file navigation (definition / references) is
not meaningful for HTML and is therefore not tested here.
"""
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import request_all_symbols
@pytest.mark.html
class TestHtmlLanguageServerBasics:
"""Smoke + symbol tests for the HTML language server."""
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.HTML], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
def test_index_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""The HTML LSP exposes elements/IDs as document symbols."""
all_symbols, _ = language_server.request_document_symbols("index.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
# vscode-html-language-server reports elements by tag name and emits an entry
# per element with an id attribute. Names contain the id like "header#page-header"
# or just the tag like "head" for elements without an id; we check both forms.
joined = " | ".join(names)
for expected_id in ("page-header", "site-title", "main-nav", "section-features", "feature-list", "page-footer"):
assert expected_id in joined, f"Expected id '{expected_id}' to appear in HTML symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
def test_about_document_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("about.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
joined = " | ".join(names)
for expected_id in ("page-header", "about-title", "main-nav", "about-article"):
assert expected_id in joined, f"Expected id '{expected_id}' to appear in HTML symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
def test_full_symbol_tree_includes_both_files(self, language_server: SolidLanguageServer) -> None:
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
assert "index.html" in relative_paths
assert "about.html" in relative_paths
@@ -0,0 +1,27 @@
"""
Diagnostics smoke test for the HTML language server.
``vscode-html-language-server`` does not validate HTML structure itself — upstream
``vscode-html-languageservice`` only forwards validation to embedded sub-services
(CSS/JS in ``<style>`` / ``<script>`` blocks), and those aren't wired up in
standalone mode. So a malformed-HTML fixture would yield zero diagnostics regardless.
Asserting "at least one diagnostic" would be a false positive waiting to happen.
Instead, this test verifies the diagnostics endpoint is reachable end-to-end and
the framework's pull→publish fallback handles the empty response correctly — i.e.
it returns a list, not an error. That alone catches LS-crash regressions on the
diagnostic request, which is the practical purpose for this LS.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
@pytest.mark.html
class TestHtmlDiagnostics:
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
def test_diagnostics_endpoint_returns_list(self, language_server: SolidLanguageServer) -> None:
diagnostics = language_server.request_text_document_diagnostics("diagnostics_sample.html", min_severity=1)
assert isinstance(diagnostics, list), diagnostics
+52
View File
@@ -96,3 +96,55 @@ class TestJavaLanguageServer:
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
@pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True)
def test_lombok_generated_methods_visible_by_default(self, language_server: SolidLanguageServer) -> None:
"""Generated Lombok methods must appear in document symbols across the common annotations.
Default `lombok_show_generated=True` sends `java.symbols.includeGeneratedCode=true` to JDTLS,
which disables the SourceMethod-isGenerated filter in DocumentSymbolHandler. Without it,
find_symbol/get_symbols_overview cannot reach Lombok-synthesised methods at all (#1432).
Covers @Data, @Builder(toBuilder=true), @With, @AllArgsConstructor, @NoArgsConstructor,
@Delegate and @Accessors(fluent=true) — every method-generating annotation listed in the
issue plus fluent prefix-stripped accessors and @Delegate forwarders.
"""
def _names_by_kind(doc, kind: int) -> set[str]:
return {sym.get("name") for sym in doc.get_all_symbols_and_roots()[0] if sym.get("kind") == kind}
SYMBOL_KIND_CLASS = 5
SYMBOL_KIND_METHOD = 6
SYMBOL_KIND_CONSTRUCTOR = 9
# ---- LombokModel: @Data + @Builder(toBuilder=true) + @With + ctors + @Delegate -------
lombok_path = os.path.join("src", "main", "java", "test_repo", "LombokModel.java")
lombok_doc = language_server.request_document_symbols(lombok_path)
lombok_methods = _names_by_kind(lombok_doc, SYMBOL_KIND_METHOD)
# @Data getters/setters (prefixed) + canonical Object overrides
for expected in ("getName", "getAge", "setName", "setAge", "equals", "hashCode", "toString"):
assert expected in lombok_methods, f"@Data did not surface {expected!r}; got: {sorted(lombok_methods)}"
# @Builder(toBuilder=true): static factory + instance toBuilder + inner build()
for expected in ("builder", "toBuilder", "build"):
assert expected in lombok_methods, f"@Builder did not surface {expected!r}; got: {sorted(lombok_methods)}"
# @With: copy-with methods
for expected in ("withName", "withAge"):
assert expected in lombok_methods, f"@With did not surface {expected!r}; got: {sorted(lombok_methods)}"
# @Delegate: forwarder methods for every method of the delegate target
for expected in ("greet", "farewell"):
assert expected in lombok_methods, f"@Delegate did not surface forwarder {expected!r}; got: {sorted(lombok_methods)}"
# @Builder generates an inner builder class
lombok_classes = _names_by_kind(lombok_doc, SYMBOL_KIND_CLASS)
assert "LombokModelBuilder" in lombok_classes, f"@Builder inner class missing; got: {sorted(lombok_classes)}"
# @AllArgsConstructor + @NoArgsConstructor surface as ctor symbols (kind=9)
lombok_ctors = _names_by_kind(lombok_doc, SYMBOL_KIND_CONSTRUCTOR)
assert "LombokModel" in lombok_ctors, f"Lombok ctors missing; got ctors {sorted(lombok_ctors)}"
# ---- FluentLombokModel: @Accessors(fluent=true) - prefix-stripped accessors ---------
fluent_path = os.path.join("src", "main", "java", "test_repo", "FluentLombokModel.java")
fluent_doc = language_server.request_document_symbols(fluent_path)
fluent_methods = _names_by_kind(fluent_doc, SYMBOL_KIND_METHOD)
for expected in ("host", "tag"):
assert expected in fluent_methods, f"@Accessors(fluent=true) did not surface {expected!r}; got: {sorted(fluent_methods)}"
View File
Whitespace-only changes.
+398
View File
@@ -0,0 +1,398 @@
"""
Basic integration tests for the SCSS language server (Some Sass).
Some Sass provides full @use/@forward workspace navigation, so this suite
exercises both in-file document symbols and cross-file go-to-definition for
variables and mixins.
"""
import os
import re
from pathlib import Path
import pytest
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes
from test.solidlsp.conftest import read_repo_file, request_all_symbols
@pytest.mark.scss
class TestScssLanguageServerBasics:
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.SCSS], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_variables_document_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("_variables.scss").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for var in ("$color-primary", "$color-secondary", "$color-text", "$space-md", "$space-lg"):
assert var in names, f"Expected variable {var} to appear in SCSS symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_mixins_document_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("_mixins.scss").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
# Some Sass surfaces @mixin and @function entries; names may be bare ("card-surface")
# or include the @-keyword. Check substring inclusion for robustness.
joined = " | ".join(names)
for expected in ("card-surface", "focus-ring", "rem"):
assert expected in joined, f"Expected '{expected}' to appear in SCSS mixin symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_buttons_document_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("buttons.scss").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
joined = " | ".join(names)
for selector in (".button", ".button-primary", ".button-secondary"):
assert selector in joined, f"Expected selector '{selector}' to appear in SCSS symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_cross_file_definition_variable(self, language_server: SolidLanguageServer) -> None:
"""`vars.$color-text` in buttons.scss must resolve into _variables.scss."""
path = "buttons.scss"
needle = "$color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the variable identifier.
definitions = language_server.request_definition(path, line, col + 2)
assert definitions, f"Expected non-empty cross-file definition list for vars.$color-text, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("_variables.scss") for uri in target_uris), (
f"Expected definition to resolve into _variables.scss, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_cross_file_definition_mixin(self, language_server: SolidLanguageServer) -> None:
"""`mix.card-surface` in buttons.scss must resolve into _mixins.scss."""
path = "buttons.scss"
needle = "card-surface"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
definitions = language_server.request_definition(path, line, col + 2)
assert definitions, f"Expected non-empty cross-file definition list for mix.card-surface, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("_mixins.scss") for uri in target_uris), (
f"Expected definition to resolve into _mixins.scss, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_cross_file_definition_function(self, language_server: SolidLanguageServer) -> None:
"""`mix.rem(16)` in main.scss must resolve into _mixins.scss (an @function)."""
path = "main.scss"
needle = "mix.rem"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the function identifier (skip the `mix.` prefix).
definitions = language_server.request_definition(path, line, col + 5)
assert definitions, f"Expected non-empty cross-file definition list for mix.rem, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("_mixins.scss") for uri in target_uris), (
f"Expected definition to resolve into _mixins.scss, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None:
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
for f in ("_variables.scss", "_mixins.scss", "buttons.scss", "main.scss"):
assert f in relative_paths, f"Expected {f} to appear in symbol tree"
@pytest.mark.scss
class TestScssReferences:
"""Find-references for symbols re-exported via @use across files."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_mixin_references_span_files(self, language_server: SolidLanguageServer) -> None:
"""References for ``card-surface`` must include both ``buttons.scss``
(`.button`, `.button-primary`, `.button-secondary` all `@include` it) and
``main.scss`` (`.panel` `@include`s it).
Some Sass returns no references when the request originates on the
``@mixin`` declaration itself, so we probe from a usage site in
``buttons.scss`` — that's also closer to how an editor user invokes
find-references in practice.
"""
path = "buttons.scss"
needle = "card-surface"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
refs = language_server.request_references(path, line, col + 2)
ref_paths = {r.get("relativePath", "") for r in refs}
assert any(p.endswith("buttons.scss") for p in ref_paths), (
f"Expected card-surface references to include buttons.scss, got: {ref_paths}"
)
assert any(p.endswith("main.scss") for p in ref_paths), f"Expected card-surface references to include main.scss, got: {ref_paths}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_variable_references_span_files(self, language_server: SolidLanguageServer) -> None:
"""`$color-primary` is read in ``buttons.scss`` (`.button-primary` background)
and ``_mixins.scss`` (default value of ``focus-ring``); references invoked
from the ``buttons.scss`` usage site must include both files.
"""
path = "buttons.scss"
needle = "$color-primary"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
refs = language_server.request_references(path, line, col + 2)
ref_paths = {r.get("relativePath", "") for r in refs}
assert any(p.endswith("buttons.scss") for p in ref_paths), (
f"Expected $color-primary references to include buttons.scss, got: {ref_paths}"
)
assert any(p.endswith("_mixins.scss") for p in ref_paths), (
f"Expected $color-primary references to include _mixins.scss (default param of focus-ring), got: {ref_paths}"
)
@pytest.mark.scss
class TestScssForward:
"""`@forward` re-exports a module; consumers should reach forwarded symbols."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_forwarded_buttons_appear_in_workspace(self, language_server: SolidLanguageServer) -> None:
"""``main.scss`` does ``@forward "buttons"``; the workspace symbol tree must
still include ``buttons.scss`` selectors so consumers of `main` can navigate.
"""
all_symbols = request_all_symbols(language_server)
button_symbols = [s for s in all_symbols if s.get("location", {}).get("relativePath") == "buttons.scss"]
names = [s["name"] for s in button_symbols]
joined = " | ".join(names)
assert ".button" in joined, f"Expected .button selector to remain reachable via @forward, got: {names}"
@pytest.mark.scss
class TestScssHover:
"""Some Sass returns rich hover content (SassDoc / value preview)."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_hover_on_variable_use(self, language_server: SolidLanguageServer) -> None:
path = "buttons.scss"
needle = "$color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
hover = language_server.request_hover(path, line, col + 2)
assert hover is not None, f"Expected hover info for $color-text in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "color-text" in text, f"Expected '$color-text' or its value in hover text, got: {text}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_hover_on_mixin_call(self, language_server: SolidLanguageServer) -> None:
path = "buttons.scss"
needle = "card-surface"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
hover = language_server.request_hover(path, line, col + 2)
assert hover is not None, f"Expected hover info for card-surface in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "card-surface" in text, f"Expected 'card-surface' in hover text, got: {text}"
@pytest.mark.scss
class TestScssCompletions:
"""Completions after a namespaced @use prefix should list re-exported members."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_completion_after_namespace_dot(self, language_server: SolidLanguageServer) -> None:
"""Completion immediately after `vars.` in buttons.scss must include the
variables defined in _variables.scss (e.g. ``$color-primary``).
"""
path = "buttons.scss"
# `color: vars.$color-text;` — invoke completion at the `.` position so the
# LSP sees the namespace prefix and offers its members.
needle = "vars.$color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Position the cursor right after the `.` (4 chars: 'v','a','r','s','.' -> idx 5)
completions = language_server.request_completions(path, line, col + 5)
labels = {c.get("completionText", "") for c in completions}
# Different Some Sass releases label vars with or without leading $; accept both.
joined = " | ".join(sorted(labels))
assert any("color-primary" in label for label in labels), f"Expected $color-primary completion after `vars.` prefix, got: {joined}"
@pytest.mark.scss
class TestScssSymbolKinds:
"""Validate that Some Sass classifies SCSS symbols with sensible LSP kinds."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_variable_symbol_kind(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("_variables.scss").get_all_symbols_and_roots()
by_name = {s["name"]: s for s in all_symbols}
assert "$color-primary" in by_name, f"Variable not in symbol list: {list(by_name)}"
kind = SymbolKind(by_name["$color-primary"]["kind"])
assert kind in (SymbolKind.Variable, SymbolKind.Constant, SymbolKind.Property), (
f"Expected $color-primary to be Variable/Constant/Property, got {kind.name}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_mixin_and_function_symbol_kinds(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("_mixins.scss").get_all_symbols_and_roots()
# Names may include the @-keyword (e.g. "@mixin card-surface") or be bare;
# match by substring.
def find_one(needle: str) -> dict:
matches = [s for s in all_symbols if needle in s["name"]]
assert matches, f"No symbol matched '{needle}' in {[s['name'] for s in all_symbols]}"
return matches[0]
mixin = find_one("card-surface")
func = find_one("rem")
# Some Sass historically reports mixins/functions as Method or Function.
# Accept either, but reject obviously-wrong kinds (e.g. Variable/Class).
callable_kinds = {SymbolKind.Method, SymbolKind.Function}
assert SymbolKind(mixin["kind"]) in callable_kinds, (
f"Expected card-surface kind in {{Method, Function}}, got {SymbolKind(mixin['kind']).name}"
)
assert SymbolKind(func["kind"]) in callable_kinds, f"Expected rem kind in {{Method, Function}}, got {SymbolKind(func['kind']).name}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_completion_kind_is_meaningful(self, language_server: SolidLanguageServer) -> None:
"""A `$variable` completion must come back with a meaningful kind. Some Sass
tags color-valued variables as ``Color`` (so editors render swatches) and
non-color variables as ``Variable``/``Property``/``Constant`` — accept any
of these but reject generic ``Text`` which would indicate the LSP failed
to classify the completion.
"""
path = "buttons.scss"
needle = "vars.$color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
completions = language_server.request_completions(path, line, col + 5)
var_items = [c for c in completions if "color-primary" in c.get("completionText", "")]
assert var_items, "no $color-primary completion item found"
accepted = {
LSPTypes.CompletionItemKind.Variable,
LSPTypes.CompletionItemKind.Property,
LSPTypes.CompletionItemKind.Constant,
LSPTypes.CompletionItemKind.Color,
LSPTypes.CompletionItemKind.Value,
}
kinds = {LSPTypes.CompletionItemKind(c["kind"]).name for c in var_items}
assert any(LSPTypes.CompletionItemKind(c["kind"]) in accepted for c in var_items), (
f"Expected variable-like CompletionItemKind for $color-primary, got: {kinds}"
)
# --- Plain CSS via Some Sass --------------------------------------------------
#
# Some Sass advertises the LSP ``css`` languageId as a first-class consumer (see
# ``packages/language-services/src/language-services-types.ts:LanguageConfiguration``)
# and dispatches per-feature handlers via the ``somesass.css.*.enabled`` toggles —
# all of which default to ``false`` upstream and which Serena flips on at init.
# These tests verify the routing actually works end-to-end against a small CSS
# fixture under ``css/`` in the same SCSS test repo.
@pytest.mark.scss
class TestSomeSassWithPlainCss:
"""``Language.SCSS`` also handles plain ``.css`` via ``some-sass-language-server``."""
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_main_css_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""Each top-level rule selector in ``main.css`` must surface as a document symbol."""
all_symbols, _ = language_server.request_document_symbols("css/main.css").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
joined = " | ".join(names)
for selector in ("body", "#page-header", "#site-title", ".button", ".button-primary", ".button-secondary"):
assert selector in joined, f"Expected selector '{selector}' to appear in CSS symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_theme_css_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""``theme.css`` contains a single ``:root`` block; the LS must report it as a symbol."""
all_symbols, _ = language_server.request_document_symbols("css/theme.css").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
joined = " | ".join(names)
assert ":root" in joined, f"Expected ':root' selector to appear in CSS symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_full_symbol_tree_includes_css_files(self, language_server: SolidLanguageServer) -> None:
"""The ``.css`` files alongside the SCSS workspace must populate the workspace symbol tree."""
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
# `relativePath` uses OS-native separators (cf. test_symbol_retrieval.py),
# so build expected paths via os.path.join to keep this test cross-platform.
for f in (os.path.join("css", "main.css"), os.path.join("css", "reset.css"), os.path.join("css", "theme.css")):
assert f in relative_paths, f"Expected {f} to appear in symbol tree"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_hover_on_css_property(self, language_server: SolidLanguageServer) -> None:
"""Hover on a CSS property name must produce non-empty MDN-backed content
(Some Sass forwards ``vscode-css-languageservice``'s property reference data).
"""
path = "css/main.css"
needle = "background-color"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
hover = language_server.request_hover(path, line, col + 2)
assert hover is not None, f"Expected hover info for background-color in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "background" in text.lower(), f"Expected hover text to mention 'background', got: {text}"
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_property_completion_in_css_rule(self, language_server: SolidLanguageServer) -> None:
"""Inside a CSS rule body the LS must offer standard property names —
proves ``somesass.css.completion.enabled = true`` is being honoured.
"""
path = "css/main.css"
needle = "padding:"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
completions = language_server.request_completions(path, line, col)
labels = {c.get("completionText", "") for c in completions}
assert any(label in labels for label in ("padding", "margin", "color", "border")), (
f"Expected at least one common CSS property name in completions, got sample: {sorted(labels)[:20]}"
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_cross_file_completion_for_css_custom_property(self, language_server: SolidLanguageServer) -> None:
"""Completion inside a ``var(...)`` call in ``main.css`` must surface the
``--color-*`` custom properties declared in ``theme.css``.
This is the single test that empirically validates Some Sass crosses file
boundaries for plain CSS — without ``somesass.css.completion.enabled = true``
the request would short-circuit to ``null`` at the handler entrypoint.
Note: ``vscode-css-languageservice`` (Some Sass' CSS engine) deliberately
does NOT implement go-to-definition for CSS custom properties yet — see
microsoft/vscode-css-languageservice#734. Completion is the closest
upstream-supported API that proves cross-file awareness.
"""
path = "css/main.css"
# `` color: var(--color-text);`` — invoke completion right after the
# leading dashes so the LS treats the request as a partial custom-property
# identifier and offers matching declarations from the workspace.
needle = "var(--color-text"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
cursor_col = col + len("var(--")
completions = language_server.request_completions(path, line, cursor_col)
labels = {c.get("completionText", "") for c in completions}
joined = " | ".join(sorted(labels))
assert any("--color-primary" in label for label in labels), (
f"Expected --color-primary completion (declared cross-file in theme.css), got: {joined}"
)
@@ -0,0 +1,42 @@
"""
Diagnostics tests for the SCSS / Sass / CSS language server (Some Sass).
Two sources are exercised — the SCSS parser (undefined ``$variable``) and the
plain-CSS parser (syntax error inside a rule body). ``somesass.css.diagnostics.enabled``
is flipped on at initialization so the .css path is not gated off; lint diagnostics
are deliberately left disabled (the upstream rules are opinionated about vendor
prefixes / empty rules / etc.) so only syntax-level errors surface here.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.scss
class TestScssDiagnostics:
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_scss_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
assert_file_diagnostics(
language_server,
"diagnostics_sample.scss",
(),
min_count=1,
)
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
def test_plain_css_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""Plain ``.css`` diagnostics flow through the same Some Sass server.
Without ``somesass.css.diagnostics.enabled = true`` (which Serena pushes via
initializationOptions), this would short-circuit to an empty list at the
handler entrypoint.
"""
assert_file_diagnostics(
language_server,
"css/diagnostics_sample.css",
(),
min_count=1,
)
@@ -0,0 +1,99 @@
"""Tests for cross-package TypeScript references using additional_workspace_folders."""
from pathlib import Path
import pytest
from solidlsp.ls_config import Language
from test.conftest import start_ls_context
CROSS_PKG_DIR = Path(__file__).parent.parent.parent / "resources" / "repos" / "typescript"
PACKAGE_A = str(CROSS_PKG_DIR / "cross_package_a")
PACKAGE_B = str(CROSS_PKG_DIR / "cross_package_b")
@pytest.mark.typescript
class TestCrossPackageReferences:
"""Verify that find_referencing_symbols works across package boundaries
when additional_workspace_folders is configured.
"""
def test_cross_package_find_references(self) -> None:
"""Starting from package_a, with package_b as additional workspace,
references in package_b should be discovered.
"""
with start_ls_context(
Language.TYPESCRIPT,
repo_path=PACKAGE_A,
additional_workspace_folders=[PACKAGE_B],
) as ls:
symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots()
shared_fn = None
for sym in symbols[0]:
if sym.get("name") == "sharedUtilityFunction":
shared_fn = sym
break
assert shared_fn is not None, "Could not find 'sharedUtilityFunction' in shared_utils.ts"
sel_start = shared_fn["selectionRange"]["start"]
refs = ls.request_references("shared_utils.ts", sel_start["line"], sel_start["character"])
ref_paths = [r.get("relativePath", "") for r in refs]
cross_package_refs = [p for p in ref_paths if "cross_package_b" in p or "consumer.ts" in p]
assert len(cross_package_refs) > 0, (
f"Expected cross-package reference from package_b/consumer.ts, but only found refs in: {ref_paths}"
)
def test_cross_package_referencing_symbols(self) -> None:
"""Test the higher-level request_referencing_symbols across packages."""
with start_ls_context(
Language.TYPESCRIPT,
repo_path=PACKAGE_A,
additional_workspace_folders=[PACKAGE_B],
) as ls:
symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots()
shared_class = None
for sym in symbols[0]:
if sym.get("name") == "SharedClass":
shared_class = sym
break
assert shared_class is not None, "Could not find 'SharedClass' in shared_utils.ts"
sel_start = shared_class["selectionRange"]["start"]
ref_symbols = ls.request_referencing_symbols(
"shared_utils.ts",
sel_start["line"],
sel_start["character"],
include_imports=True,
include_file_symbols=True,
)
ref_files = [
r.symbol["location"]["relativePath"]
for r in ref_symbols
if "location" in r.symbol and "relativePath" in r.symbol["location"]
]
cross_refs = [p for p in ref_files if "cross_package_b" in p or "consumer.ts" in p]
assert len(cross_refs) > 0, f"Expected cross-package referencing symbol from package_b, but only found refs in: {ref_files}"
def test_without_additional_workspace_no_cross_refs(self) -> None:
"""Baseline: without additional_workspace_folders, cross-package refs should NOT appear."""
with start_ls_context(
Language.TYPESCRIPT,
repo_path=PACKAGE_A,
) as ls:
symbols = ls.request_document_symbols("shared_utils.ts").get_all_symbols_and_roots()
shared_fn = None
for sym in symbols[0]:
if sym.get("name") == "sharedUtilityFunction":
shared_fn = sym
break
assert shared_fn is not None
sel_start = shared_fn["selectionRange"]["start"]
refs = ls.request_references("shared_utils.ts", sel_start["line"], sel_start["character"])
ref_paths = [r.get("relativePath", "") for r in refs]
cross_package_refs = [p for p in ref_paths if "cross_package_b" in p or "consumer.ts" in p]
assert len(cross_package_refs) == 0, (
f"Without additional_workspace_folders, should NOT find cross-package refs, but found: {cross_package_refs}"
)