mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 04:16:34 +00:00
Merge branch 'main' into hook_extension
# Conflicts: # src/solidlsp/ls_config.py
This commit is contained in:
commit
53d5e6e898
74 files changed
+3962
-259
No files matched your search
@@ -564,6 +564,13 @@ jobs:
|
||||
uses: leanprover/lean-action@v1
|
||||
with:
|
||||
lake-package-directory: test/resources/repos/lean4/test_repo
|
||||
- name: Install npm deps for Angular test repo
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -d test/resources/repos/angular/test_repo ]; then
|
||||
cd test/resources/repos/angular/test_repo
|
||||
npm install --no-audit --no-fund --loglevel=warn
|
||||
fi
|
||||
- name: Cache language servers
|
||||
id: cache-language-servers
|
||||
uses: actions/cache@v3
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -142,10 +142,13 @@ Only the language servers listed below download or install additional dependenci
|
||||
|
||||
### npm Package Installs
|
||||
|
||||
- **Angular**: `@angular/language-server`, `@angular/language-service`, plus `typescript` and `typescript-language-server`
|
||||
- **Ansible**: `@ansible/ansible-language-server`
|
||||
- **Bash**: `bash-language-server`
|
||||
- **Elm**: `@elm-tooling/elm-language-server`
|
||||
- **HTML**: `vscode-langservers-extracted` (provides `vscode-html-language-server`)
|
||||
- **PHP (`intelephense`)**: `intelephense`
|
||||
- **SCSS / Sass / CSS**: `some-sass-language-server`
|
||||
- **Solidity**: `@nomicfoundation/solidity-language-server`
|
||||
- **TypeScript**: `typescript` and `typescript-language-server`
|
||||
- **Vue**: `@vue/language-server`, plus `typescript` and `typescript-language-server`
|
||||
|
||||
@@ -349,6 +349,9 @@ markers = [
|
||||
"solidity: language server running for Solidity (uses @nomicfoundation/solidity-language-server)",
|
||||
"ansible: language server running for Ansible (uses @ansible/ansible-language-server)",
|
||||
"msl: language server running for mSL (mIRC Scripting Language)",
|
||||
"html: language server running for HTML (vscode-html-language-server, used as companion by Angular)",
|
||||
"scss: language server running for SCSS / Sass / CSS (some-sass-language-server)",
|
||||
"angular: language server running for Angular (uses @angular/language-server + @angular/language-service tsserver plugin)",
|
||||
]
|
||||
|
||||
[tool.codespell]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"name": "serena-angular-test-repo",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "serena-angular-test-repo",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.0.0",
|
||||
"@angular/compiler": "^21.0.0",
|
||||
"@angular/core": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.6.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "21.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.10.tgz",
|
||||
"integrity": "sha512-WLyi/CRLtgALg2mmaqIuKuPnE4i+8PGt/uuz26pVqx+ASh28/TWr5KSCAMomgxEc8kt4OE7lopoQsTihrQCfEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "21.2.10",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "21.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.10.tgz",
|
||||
"integrity": "sha512-IrgdFuzzD7NTK3WQaSfowjAPxPbnTqsgR92NsOs5ZaWu3RgLl21dHThNc0BK1KwVwppLUSWmD4qePbcLW71VzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "21.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.10.tgz",
|
||||
"integrity": "sha512-uxH+mbPiCE7rInWKYOPe9Ytas97+mFM6FhFORoN234yBK3b8he+iDuxX6dsbhEFCxhRmfS6hLxe7BdLY6U6kIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "21.2.10",
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.15.0 || ~0.16.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"zone.js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "21.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.10.tgz",
|
||||
"integrity": "sha512-5WMoHGU8BOV3eO9h3vGMIUDPf+3SHis7+X2dHKMtKfFBUtiO8m/lq2x3PzkkKj1782i7KYt92EqPHuADd/eWOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "21.2.10",
|
||||
"@angular/common": "21.2.10",
|
||||
"@angular/core": "21.2.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/zone.js": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
||||
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "serena-angular-test-repo",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Minimal Angular workspace fixture used by Serena language-server tests.",
|
||||
"dependencies": {
|
||||
"@angular/common": "^21.0.0",
|
||||
"@angular/compiler": "^21.0.0",
|
||||
"@angular/core": "^21.0.0",
|
||||
"@angular/platform-browser": "^21.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.6.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<section>
|
||||
<h1>{{ title() | exclaim }}</h1>
|
||||
<p>{{ greeting() }}</p>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
[value]="userName()"
|
||||
(input)="setName($any($event.target).value)"
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
|
||||
@if (items().length > 0) {
|
||||
<ul>
|
||||
@for (item of items(); track item) {
|
||||
<app-item-card [label]="item"></app-item-card>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p>No items.</p>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { GreetingService } from './greeting.service';
|
||||
import { ItemCardComponent } from './item-card.component';
|
||||
import { ExclaimPipe } from './exclaim.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [ItemCardComponent, ExclaimPipe],
|
||||
templateUrl: './app.component.html',
|
||||
})
|
||||
export class AppComponent {
|
||||
readonly title = signal('Serena Angular Test');
|
||||
readonly userName = signal('');
|
||||
readonly items = signal<string[]>(['alpha', 'beta', 'gamma']);
|
||||
|
||||
constructor(private readonly greetings: GreetingService) {}
|
||||
|
||||
greeting(): string {
|
||||
return this.greetings.greet(this.userName() || undefined);
|
||||
}
|
||||
|
||||
setName(name: string): void {
|
||||
this.userName.set(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Template path: ngserver flags the reference to ``undefinedSignal()`` since
|
||||
it has no matching field on DiagnosticsSampleComponent. The standalone .html
|
||||
diagnostic exercises the Angular template compiler, not plain-HTML validation. -->
|
||||
<section>
|
||||
<h1>{{ undefinedSignal() }}</h1>
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
// Two diagnostic sources in one fixture:
|
||||
// * the type error on ``count`` is reported by tsserver via the Angular plugin
|
||||
// when diagnosing this .ts file directly.
|
||||
// * the ``undefinedSignal()`` reference in diagnostics_sample.html is reported
|
||||
// by ngserver when diagnosing the template attached via ``templateUrl``.
|
||||
@Component({
|
||||
selector: 'app-diagnostics-sample',
|
||||
standalone: true,
|
||||
templateUrl: './diagnostics_sample.html',
|
||||
})
|
||||
export class DiagnosticsSampleComponent {
|
||||
readonly count: number = 'not-a-number';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'exclaim',
|
||||
standalone: true,
|
||||
})
|
||||
export class ExclaimPipe implements PipeTransform {
|
||||
transform(value: string, count: number = 1): string {
|
||||
return value + '!'.repeat(count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface Greeter {
|
||||
greet(name?: string): string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Greeter } from './greeter.interface';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GreetingService implements Greeter {
|
||||
private readonly defaultName = 'World';
|
||||
|
||||
greet(name?: string): string {
|
||||
return `Hello, ${name ?? this.defaultName}!`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-item-card',
|
||||
standalone: true,
|
||||
template: `
|
||||
<li class="item-card" [attr.data-label]="label">
|
||||
<strong>{{ label }}</strong>
|
||||
</li>
|
||||
`,
|
||||
})
|
||||
export class ItemCardComponent {
|
||||
@Input() label: string = '';
|
||||
|
||||
formatLabel(): string {
|
||||
return this.label.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Serena Angular Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent).catch((err) => console.error(err));
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.html"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/out-tsc",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>About — Serena HTML Test</title>
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="page-header">
|
||||
<h1 id="about-title">About</h1>
|
||||
<nav id="main-nav">
|
||||
<a href="index.html">Home</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="page-main">
|
||||
<article id="about-article">
|
||||
<h2>About this repo</h2>
|
||||
<p>Used by the Serena language server tests.</p>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!-- Diagnostics smoke fixture — vscode-html-language-server does not emit
|
||||
diagnostics for HTML structure; this file just gives the LS something to
|
||||
chew on so the diagnostics endpoint round-trip can be exercised. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Diagnostics Sample</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="dup">first</div>
|
||||
<div id="dup">second</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Serena HTML Test</title>
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="page-header">
|
||||
<h1 id="site-title">Serena HTML Test Repo</h1>
|
||||
<nav id="main-nav">
|
||||
<a href="about.html">About</a>
|
||||
<a href="#section-features">Features</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="page-main">
|
||||
<section id="section-intro">
|
||||
<h2>Introduction</h2>
|
||||
<p>A minimal HTML test fixture used by the Serena language-server tests.</p>
|
||||
</section>
|
||||
<section id="section-features">
|
||||
<h2>Features</h2>
|
||||
<ul id="feature-list">
|
||||
<li>Symbol detection</li>
|
||||
<li>Element navigation</li>
|
||||
<li>Cross-file links</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<footer id="page-footer">
|
||||
<p>© Serena</p>
|
||||
</footer>
|
||||
<script src="scripts/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Pytest fixtures for Angular language server tests.
|
||||
|
||||
This conftest is NOT installing the language server itself — that is fully handled
|
||||
by ``RuntimeDependencyCollection`` inside ``AngularLanguageServer`` and lands in
|
||||
Serena's managed ``ls_resources_dir`` like every other LS download.
|
||||
|
||||
What we install here is the *test fixture project's* npm dependencies (notably
|
||||
``@angular/core``) into the fixture's own ``node_modules``. ngserver requires
|
||||
``@angular/core`` to be resolvable from the workspace root or it silently treats
|
||||
every file as "not in an Angular project", at which point all template-aware
|
||||
features (definition / hover / references on .html, cross-file template→component
|
||||
navigation) return empty results — tests would pass vacuously while exercising
|
||||
nothing. Vendoring ``node_modules`` in-repo is impractical (~500MB), so we run
|
||||
``npm install`` once per checkout, cached across sessions, and serialised across
|
||||
xdist workers via ``filelock``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from filelock import FileLock
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2] / "resources" / "repos" / "angular" / "test_repo"
|
||||
NODE_MODULES = REPO_ROOT / "node_modules"
|
||||
ANGULAR_CORE_MARKER = NODE_MODULES / "@angular" / "core" / "package.json"
|
||||
# Lock file lives inside the repo (covered by its .gitignore) so xdist workers
|
||||
# can serialise on the install. Putting it elsewhere risks placing it on a
|
||||
# different filesystem from the install dir, which makes flock semantics fuzzy.
|
||||
INSTALL_LOCK = REPO_ROOT / ".angular-install.lock"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _install_angular_test_repo_node_modules() -> None:
|
||||
"""
|
||||
Populate the *test fixture project's* ``node_modules`` (NOT the language server's
|
||||
install; see module docstring for the distinction).
|
||||
|
||||
Cached across sessions: once ``node_modules/@angular/core/package.json`` exists,
|
||||
this is a no-op. Skipped (with the test session marked) if npm is unavailable.
|
||||
|
||||
Under pytest-xdist multiple workers race on the install. ``npm install`` does
|
||||
not internally serialize concurrent runs against the same install dir, so we
|
||||
take an exclusive ``filelock`` around the whole install/check sequence — the
|
||||
second worker into the lock will then short-circuit on the marker check.
|
||||
"""
|
||||
if ANGULAR_CORE_MARKER.exists():
|
||||
log.info("Angular test repo node_modules already populated; skipping npm install")
|
||||
return
|
||||
|
||||
if shutil.which("npm") is None:
|
||||
pytest.skip("npm is not available; cannot install Angular test repo dependencies")
|
||||
|
||||
with FileLock(str(INSTALL_LOCK)):
|
||||
# Re-check inside the lock: a sibling worker may have just installed.
|
||||
if ANGULAR_CORE_MARKER.exists():
|
||||
log.info("Angular test repo node_modules populated by another worker; skipping npm install")
|
||||
return
|
||||
|
||||
log.warning(
|
||||
"Installing npm dependencies into the Angular test repo at %s. This is a one-time cost per checkout and may take ~30s.",
|
||||
REPO_ROOT,
|
||||
)
|
||||
|
||||
proc = subprocess.run(
|
||||
["npm", "install", "--no-audit", "--no-fund", "--loglevel=warn"],
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr)
|
||||
pytest.skip(f"npm install failed in {REPO_ROOT} (rc={proc.returncode}); see logs for details")
|
||||
|
||||
if not ANGULAR_CORE_MARKER.exists():
|
||||
pytest.skip(f"npm install completed but {ANGULAR_CORE_MARKER} is missing; cannot run Angular tests")
|
||||
|
||||
log.info("Angular test repo node_modules installed successfully")
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
Basic integration tests for the Angular language server.
|
||||
|
||||
The Angular LS understands Angular template syntax (*ngIf, [prop], (event),
|
||||
{{ interpolation }}, @if/@for blocks) and provides type-aware navigation
|
||||
between templates and component classes.
|
||||
|
||||
Workspace: a minimal standalone-component Angular app with a service that the
|
||||
component injects, plus a template (.html) that interpolates component methods.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from serena.util.text_utils import find_text_coordinates
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from test.solidlsp.conftest import read_repo_file, request_all_symbols
|
||||
|
||||
|
||||
@pytest.mark.angular
|
||||
class TestAngularLanguageServerBasics:
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.ANGULAR], indirect=True)
|
||||
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
|
||||
assert language_server.is_running()
|
||||
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_component_class_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The Angular LS exposes the component class methods/fields via tsserver."""
|
||||
all_symbols, _ = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
for expected in ("AppComponent", "title", "userName", "items", "greeting", "setName"):
|
||||
assert expected in names, f"Expected '{expected}' in component symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_service_class_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols, _ = language_server.request_document_symbols("src/app/greeting.service.ts").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
for expected in ("GreetingService", "greet", "defaultName"):
|
||||
assert expected in names, f"Expected '{expected}' in service symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols = request_all_symbols(language_server)
|
||||
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
|
||||
# The HTML companion is unconditionally started, so .html documentSymbol
|
||||
# contributes structural element entries to the workspace tree alongside
|
||||
# the two TS files.
|
||||
# relativePath uses OS-native separators (Path.relative_to), so build expectations the same way.
|
||||
for f in (
|
||||
os.path.join("src", "app", "app.component.ts"),
|
||||
os.path.join("src", "app", "greeting.service.ts"),
|
||||
os.path.join("src", "app", "app.component.html"),
|
||||
):
|
||||
assert f in relative_paths, f"Expected {f} to appear in symbol tree, got {relative_paths}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_template_definition_to_component_method(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Resolve `greeting()` interpolation in the template to its component method.
|
||||
|
||||
Cursor lands inside the ``greeting`` identifier on the ``{{ greeting() }}``
|
||||
line of ``app.component.html``.
|
||||
"""
|
||||
path = "src/app/app.component.html"
|
||||
needle = "greeting()"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
# +1 puts the cursor inside the identifier rather than on its leading boundary.
|
||||
definitions = language_server.request_definition(path, line, col + 1)
|
||||
assert definitions, f"Expected non-empty cross-file definition for template->component method, got {definitions}"
|
||||
target_uris = [d["uri"] for d in definitions]
|
||||
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
|
||||
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_plain_html_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
"""DocumentSymbol on plain index.html should come back from the HTML companion.
|
||||
|
||||
ngserver returns -32601 for documentSymbol on every .html file (plain or
|
||||
Angular template). The AngularLanguageServer routes .html documentSymbol
|
||||
to a tertiary vscode-html-language-server companion so users get a
|
||||
structural element outline instead of an empty list.
|
||||
"""
|
||||
all_symbols, _ = language_server.request_document_symbols("src/index.html").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
# index.html contains <html>, <head>, <meta>, <title>, <body>, <app-root>
|
||||
for expected in ("html", "head", "body", "app-root"):
|
||||
assert expected in names, f"Expected '{expected}' in plain-HTML symbol list, got: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_template_html_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
"""DocumentSymbol on an Angular template must return the HTML element tree.
|
||||
|
||||
Angular template syntax (``@if``/``@for``/``{{ }}``) is ignored by the
|
||||
vscode-html-language-server parser but the surrounding element structure
|
||||
(``<section>``, ``<h1>``, ``<p>``, ``<input>``, ``<ul>``, ``<app-item-card>``)
|
||||
is still reported, which is the intended outline.
|
||||
"""
|
||||
all_symbols, _ = language_server.request_document_symbols("src/app/app.component.html").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
for expected in ("section", "h1", "p", "input", "ul", "app-item-card"):
|
||||
assert expected in names, f"Expected '{expected}' in template HTML symbol list, got: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_ts_method_references_include_template_usage(self, language_server: SolidLanguageServer) -> None:
|
||||
"""References on a .ts component method must include its template callers.
|
||||
|
||||
``setName`` is defined in app.component.ts and bound in the template via
|
||||
``(input)="setName(...)"``. On real Angular projects the typescript-language-server
|
||||
alone misses the template callers, so the Angular LS routes .ts references
|
||||
through ngserver which aggregates both .ts and .html usages.
|
||||
"""
|
||||
src_path = "src/app/app.component.ts"
|
||||
# Probe at the declaration site of setName, not a usage; the regex anchors on the
|
||||
# parameter list signature so we always land on the method header even if calls move.
|
||||
coords = find_text_coordinates(read_repo_file(language_server, src_path), r"(setName)\(name:")
|
||||
assert coords is not None, "Could not locate setName declaration in app.component.ts"
|
||||
# +1 puts the cursor inside the identifier rather than on its leading boundary.
|
||||
refs = language_server.request_references(src_path, coords.line, coords.col + 1)
|
||||
ref_paths = {r.get("relativePath", "") for r in refs}
|
||||
assert any(p.endswith("app.component.html") for p in ref_paths), (
|
||||
f"Expected references for setName to include its template callsite in app.component.html, got: {ref_paths}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.angular
|
||||
class TestAngularHover:
|
||||
"""Hover routing — .ts goes to the companion tsserver, .html goes to ngserver."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_hover_on_ts_method(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Hover on a .ts method declaration is routed through the companion TS server
|
||||
and must yield a non-empty MarkupContent describing the method signature.
|
||||
"""
|
||||
path = "src/app/app.component.ts"
|
||||
needle = "setName("
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
# Cursor inside the identifier (one past the leading 's') so the LSP
|
||||
# treats the position as the symbol rather than a token boundary.
|
||||
hover = language_server.request_hover(path, line, col + 1)
|
||||
assert hover is not None, f"Expected hover info for setName in {path}, got None"
|
||||
contents = hover.get("contents")
|
||||
assert contents, f"Expected non-empty hover contents, got: {hover}"
|
||||
text = contents["value"] if isinstance(contents, dict) else str(contents)
|
||||
assert "setName" in text, f"Expected setName in hover text, got: {text}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_hover_on_template_method_call(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Hover on a method call inside an Angular template ({{ greeting() }}) goes
|
||||
through ngserver and must yield Angular-aware type info.
|
||||
"""
|
||||
path = "src/app/app.component.html"
|
||||
needle = "greeting()"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
# Cursor inside the identifier (one past the leading 'g').
|
||||
hover = language_server.request_hover(path, line, col + 1)
|
||||
assert hover is not None, f"Expected hover info for greeting() in {path}, got None"
|
||||
contents = hover.get("contents")
|
||||
assert contents, f"Expected non-empty hover contents, got: {hover}"
|
||||
|
||||
|
||||
@pytest.mark.angular
|
||||
class TestAngularDefinitionRouting:
|
||||
"""Cross-file definition for the binding flavours not covered by basic tests."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_definition_from_property_binding(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Property binding ``[value]="userName()"`` must resolve to the component's
|
||||
``userName`` signal field declaration in app.component.ts.
|
||||
"""
|
||||
path = "src/app/app.component.html"
|
||||
needle = '[value]="userName()"'
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
# Cursor on the 'u' of userName inside the binding expression
|
||||
col = col + len('[value]="')
|
||||
definitions = language_server.request_definition(path, line, col)
|
||||
assert definitions, f"Expected non-empty definition for userName binding, got {definitions}"
|
||||
target_uris = [d["uri"] for d in definitions]
|
||||
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
|
||||
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_definition_from_event_binding(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Event binding ``(input)="setName(...)"`` must resolve to the component's
|
||||
``setName`` method declaration in app.component.ts.
|
||||
"""
|
||||
path = "src/app/app.component.html"
|
||||
needle = '(input)="setName('
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
col = col + len('(input)="') # cursor on 's' of setName
|
||||
definitions = language_server.request_definition(path, line, col)
|
||||
assert definitions, f"Expected non-empty definition for setName binding, got {definitions}"
|
||||
target_uris = [d["uri"] for d in definitions]
|
||||
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
|
||||
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_definition_service_import_in_component(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The ``GreetingService`` symbol used in the constructor parameter list
|
||||
of AppComponent must resolve to greeting.service.ts via the companion TS server.
|
||||
"""
|
||||
path = "src/app/app.component.ts"
|
||||
# Probe at the constructor signature, not the import line, to make sure
|
||||
# we exercise the type-resolution path rather than the module-resolution path.
|
||||
needle = "private readonly greetings: GreetingService"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
col = col + len("private readonly greetings: ") # cursor on 'G' of GreetingService
|
||||
definitions = language_server.request_definition(path, line, col)
|
||||
assert definitions, f"Expected definition for GreetingService, got {definitions}"
|
||||
target_uris = [d["uri"] for d in definitions]
|
||||
assert any(uri.endswith("greeting.service.ts") for uri in target_uris), (
|
||||
f"Expected definition to resolve into greeting.service.ts, got URIs: {target_uris}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_definition_from_child_component_selector(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The ``<app-item-card>`` element in the parent template must resolve to the
|
||||
ItemCardComponent class declaration in item-card.component.ts.
|
||||
|
||||
This is an Angular-specific feature: the @angular/language-service tsserver
|
||||
plugin makes the selector string a navigable type-reference back to its
|
||||
component class.
|
||||
"""
|
||||
path = "src/app/app.component.html"
|
||||
needle = "app-item-card"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
col = col + 1 # cursor inside 'app-item-card'
|
||||
definitions = language_server.request_definition(path, line, col)
|
||||
assert definitions, f"Expected definition for <app-item-card>, got {definitions}"
|
||||
target_uris = [d["uri"] for d in definitions]
|
||||
assert any(uri.endswith("item-card.component.ts") for uri in target_uris), (
|
||||
f"Expected definition to resolve into item-card.component.ts, got URIs: {target_uris}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.angular
|
||||
class TestAngularRename:
|
||||
"""Rename routing returns a WorkspaceEdit without applying it (safe for fixtures)."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_rename_method_returns_edits_for_ts_and_template(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Renaming the ``setName`` method from its .ts declaration must return a
|
||||
WorkspaceEdit that touches both app.component.ts (declaration + any TS calls)
|
||||
and app.component.html (the ``(input)="setName(...)"`` binding).
|
||||
"""
|
||||
path = "src/app/app.component.ts"
|
||||
# Anchor on the declaration site (signature with the parameter list), not a usage.
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), r"(setName)\(name:")
|
||||
assert coords is not None, "Could not locate setName declaration in app.component.ts"
|
||||
# +1 puts the cursor inside the identifier rather than on its leading boundary.
|
||||
edit = language_server.request_rename_symbol_edit(path, coords.line, coords.col + 1, "updateName")
|
||||
assert edit is not None, "Expected WorkspaceEdit, got None"
|
||||
changes = edit.get("changes") or {}
|
||||
# Some servers return ``documentChanges`` instead of (or in addition to) ``changes``.
|
||||
document_changes = edit.get("documentChanges") or []
|
||||
all_uris: set[str] = set(changes.keys())
|
||||
for dc in document_changes:
|
||||
uri = (dc.get("textDocument") or {}).get("uri") or dc.get("uri")
|
||||
if uri:
|
||||
all_uris.add(uri)
|
||||
assert any(uri.endswith("app.component.ts") for uri in all_uris), f"Expected rename edits in app.component.ts, got URIs: {all_uris}"
|
||||
assert any(uri.endswith("app.component.html") for uri in all_uris), (
|
||||
f"Expected rename edits in app.component.html (template binding), got URIs: {all_uris}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.angular
|
||||
class TestAngularSymbolStructure:
|
||||
"""Hierarchical symbol structure — class symbols must contain method/field children."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_component_class_has_methods_and_fields_as_children(self, language_server: SolidLanguageServer) -> None:
|
||||
"""``request_document_symbols`` should return AppComponent as a class symbol
|
||||
whose children include its methods (``greeting``, ``setName``) and signal
|
||||
fields (``title``, ``userName``, ``items``). Flat-name checks pass even when
|
||||
the hierarchy is broken; this test asserts the parent-child relationship.
|
||||
"""
|
||||
all_symbols, root_symbols = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots()
|
||||
|
||||
component = next((s for s in all_symbols if s.get("name") == "AppComponent"), None)
|
||||
assert component is not None, "AppComponent class symbol not found"
|
||||
|
||||
children = component.get("children") or []
|
||||
child_names = {c.get("name") for c in children}
|
||||
for expected in ("greeting", "setName", "title", "userName", "items"):
|
||||
assert expected in child_names, f"Expected '{expected}' as child of AppComponent, got: {child_names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_pipe_class_in_symbol_tree(self, language_server: SolidLanguageServer) -> None:
|
||||
"""A custom pipe (``ExclaimPipe``) declared in exclaim.pipe.ts must appear in
|
||||
the workspace symbol tree alongside the components and the service.
|
||||
"""
|
||||
all_symbols = request_all_symbols(language_server)
|
||||
names = {s.get("name") for s in all_symbols}
|
||||
for expected in ("ExclaimPipe", "ItemCardComponent", "AppComponent", "GreetingService"):
|
||||
assert expected in names, f"Expected '{expected}' in full symbol tree, got: {sorted(names)}"
|
||||
|
||||
|
||||
@pytest.mark.angular
|
||||
class TestAngularImplementations:
|
||||
"""``textDocument/implementation`` is routed through tsserver via the Angular plugin."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_find_implementations_of_interface_method(self, language_server: SolidLanguageServer) -> None:
|
||||
"""``Greeter.greet`` is implemented by ``GreetingService``; ``request_implementation``
|
||||
invoked at the interface declaration must point at the service.
|
||||
|
||||
Routed through the Angular LS' companion typescript-language-server (with the
|
||||
@angular/language-service plugin loaded as a tsserver plugin) — bare ngserver
|
||||
does not own ``textDocument/implementation``.
|
||||
"""
|
||||
path = "src/app/greeter.interface.ts"
|
||||
needle = "greet("
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
# Cursor inside the identifier (one past 'g').
|
||||
implementations = language_server.request_implementation(path, line, col + 1)
|
||||
assert implementations, f"Expected at least one implementation of Greeter.greet, got {implementations}"
|
||||
target_uris = [impl["uri"] for impl in implementations]
|
||||
assert any(uri.endswith("greeting.service.ts") for uri in target_uris), (
|
||||
f"Expected implementation to resolve into greeting.service.ts, got URIs: {target_uris}"
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Diagnostics tests for the Angular language server.
|
||||
|
||||
Two paths are exercised:
|
||||
* .ts component — tsserver (via the @angular/language-service plugin) reports
|
||||
the type-mismatch on a class field initializer.
|
||||
* .html template — ngserver reports the unresolved identifier in a template
|
||||
interpolation, but only because the template is attached to a @Component via
|
||||
``templateUrl``. Bare .html files are not type-checked by ngserver.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from test.solidlsp.util.diagnostics import assert_file_diagnostics
|
||||
|
||||
|
||||
@pytest.mark.angular
|
||||
class TestAngularDiagnostics:
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_component_class_diagnostics(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The component's ``count: number = 'not-a-number'`` must be flagged by tsserver."""
|
||||
assert_file_diagnostics(
|
||||
language_server,
|
||||
"src/app/diagnostics_sample.ts",
|
||||
(),
|
||||
min_count=1,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_template_diagnostics(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The template's ``{{ undefinedSignal() }}`` must be flagged by ngserver.
|
||||
|
||||
Routed through the Angular template compiler, which only checks templates
|
||||
attached to a @Component — see the companion ``diagnostics_sample.ts`` that
|
||||
wires this file via ``templateUrl``.
|
||||
"""
|
||||
assert_file_diagnostics(
|
||||
language_server,
|
||||
"src/app/diagnostics_sample.html",
|
||||
(),
|
||||
min_count=1,
|
||||
)
|
||||
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Error / edge-case tests for the Angular language server.
|
||||
|
||||
The Angular LS routes by file extension across three child processes:
|
||||
* ``ngserver`` for ``.html`` definition / references / hover
|
||||
* companion ``typescript-language-server`` (with @angular/language-service
|
||||
loaded as a tsserver plugin) for ``.ts`` operations
|
||||
* companion ``vscode-html-language-server`` for ``.html`` documentSymbol
|
||||
|
||||
The behaviour locked in below is **observed** behaviour on Linux with the
|
||||
versions pinned in ``angular_language_server.py`` (Angular LS 21.2.10,
|
||||
typescript-language-server 5.1.3, TypeScript 5.9.3). It is platform-aware where
|
||||
upstream LSPs are known to differ (Windows TS server tends to swallow malformed
|
||||
positions instead of raising).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_exceptions import SolidLSPException
|
||||
from test.conftest import _create_ls
|
||||
|
||||
pytestmark = pytest.mark.angular
|
||||
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
TS_FILE = os.path.join("src", "app", "app.component.ts")
|
||||
SERVICE_FILE = os.path.join("src", "app", "greeting.service.ts")
|
||||
TEMPLATE_FILE = os.path.join("src", "app", "app.component.html")
|
||||
|
||||
|
||||
class TestAngularInvalidPositionsOnTs:
|
||||
"""Negative / out-of-bounds line and column on .ts files (TS-companion route)."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_negative_line_number_containing_symbol(self, language_server: SolidLanguageServer) -> None:
|
||||
"""``request_containing_symbol`` short-circuits before reaching the LS:
|
||||
a negative line returns None.
|
||||
"""
|
||||
result = language_server.request_containing_symbol(TS_FILE, -1, 0)
|
||||
assert result is None, f"Expected None for negative line, got: {result!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_negative_character_number_containing_symbol(self, language_server: SolidLanguageServer) -> None:
|
||||
result = language_server.request_containing_symbol(TS_FILE, 5, -1)
|
||||
assert result is None, f"Expected None for negative character, got: {result!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The wrapper code raises ``IndexError`` before reaching the LS."""
|
||||
with pytest.raises(IndexError) as exc_info:
|
||||
language_server.request_containing_symbol(TS_FILE, 99999, 0)
|
||||
assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_character_beyond_line_length_returns_enclosing_class(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Character far beyond end-of-line is clamped by the LSP. Line 5 of
|
||||
app.component.ts is inside ``export class AppComponent { ... }``, so the
|
||||
TS companion (with the Angular plugin) returns the AppComponent class
|
||||
as the containing symbol — not None — which is the documented LSP
|
||||
behaviour.
|
||||
"""
|
||||
result = language_server.request_containing_symbol(TS_FILE, 5, 99999)
|
||||
assert isinstance(result, dict), f"Expected dict (containing class), got: {result!r}"
|
||||
assert result.get("name") == "AppComponent", f"Expected containing symbol 'AppComponent', got: {result.get('name')!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_references_at_negative_line_returns_empty(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The Angular LS's TS companion returns ``[]`` for negative-line
|
||||
``request_references`` — it does **not** raise. (Plain Vue tests
|
||||
observe a ``Bad line number`` raise on Linux/macOS; the Angular
|
||||
plugin layered on tsserver suppresses this.)
|
||||
"""
|
||||
result = language_server.request_references(TS_FILE, -1, 0)
|
||||
assert result == [], f"Expected [], got: {result!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_definition_at_negative_position_returns_empty(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Same as references: TS companion returns ``[]``, does not raise."""
|
||||
result = language_server.request_definition(TS_FILE, -1, 0)
|
||||
assert result == [], f"Expected [], got: {result!r}"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
IS_WINDOWS,
|
||||
reason="Windows ngserver swallows malformed positions instead of raising — same divergence as the TS companion (see test_referencing_symbols_at_invalid_position_raises).",
|
||||
)
|
||||
class TestAngularInvalidPositionsOnTemplate:
|
||||
"""Negative / out-of-bounds positions on .html files (ngserver route)."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_negative_line_definition_raises(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Ngserver does **not** swallow malformed positions: it surfaces a
|
||||
``Debug Failure. False expression.`` error from its underlying
|
||||
compiler. Lock that behaviour to catch any future change.
|
||||
"""
|
||||
with pytest.raises(SolidLSPException) as exc_info:
|
||||
language_server.request_definition(TEMPLATE_FILE, -1, 0)
|
||||
assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), (
|
||||
f"Unexpected exception message: {exc_info.value}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_character_beyond_line_definition_raises(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Same path as negative line — ngserver raises Debug Failure."""
|
||||
with pytest.raises(SolidLSPException) as exc_info:
|
||||
language_server.request_definition(TEMPLATE_FILE, 0, 99999)
|
||||
assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), (
|
||||
f"Unexpected exception message: {exc_info.value}"
|
||||
)
|
||||
|
||||
|
||||
class TestAngularNonExistentFiles:
|
||||
"""Requests against files that don't exist must raise FileNotFoundError consistently."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_nonexistent_ts_file_raises(self, language_server: SolidLanguageServer) -> None:
|
||||
nonexistent = os.path.join("src", "app", "does-not-exist.component.ts")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
language_server.request_references(nonexistent, 0, 0)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
language_server.request_definition(nonexistent, 0, 0)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
language_server.request_document_symbols(nonexistent)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_nonexistent_html_file_raises(self, language_server: SolidLanguageServer) -> None:
|
||||
nonexistent = os.path.join("src", "app", "does-not-exist.component.html")
|
||||
with pytest.raises(FileNotFoundError):
|
||||
language_server.request_definition(nonexistent, 0, 0)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
language_server.request_document_symbols(nonexistent)
|
||||
|
||||
|
||||
class TestAngularUndefinedSymbols:
|
||||
"""Symbols that have no callers / definitions / referencing positions."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_definition_at_keyword_position_returns_empty(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Cursor on the ``import`` keyword (line 0, col 0 of app.component.ts)
|
||||
has no definition target — the TS companion returns ``[]``.
|
||||
"""
|
||||
result = language_server.request_definition(TS_FILE, 0, 0)
|
||||
assert result == [], f"Expected [] for keyword position, got: {result!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_references_for_local_const_have_few_callers(self, language_server: SolidLanguageServer) -> None:
|
||||
"""A locally-scoped private field has at most its declaration plus its
|
||||
in-method use as references. ``defaultName`` on GreetingService is
|
||||
used only inside ``greet()``.
|
||||
"""
|
||||
all_symbols, _ = language_server.request_document_symbols(SERVICE_FILE).get_all_symbols_and_roots()
|
||||
sym = next((s for s in all_symbols if s.get("name") == "defaultName"), None)
|
||||
assert sym is not None, "defaultName symbol missing in fixture"
|
||||
sel = sym["selectionRange"]["start"]
|
||||
refs = language_server.request_references(SERVICE_FILE, sel["line"], sel["character"])
|
||||
assert isinstance(refs, list), f"Expected list, got {type(refs)}"
|
||||
# 1 declaration + 1 internal read = 2 expected; allow 1-3 to absorb
|
||||
# whether the LSP includes the declaration.
|
||||
assert 1 <= len(refs) <= 3, f"defaultName should have 1-3 references, got {len(refs)}: {refs}"
|
||||
|
||||
|
||||
class TestAngularEdgeCasePositions:
|
||||
"""Position (0,0), whitespace lines, and other boundary conditions."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_containing_symbol_at_file_start_is_none(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Line 0 of app.component.ts is an ``import`` statement, outside any
|
||||
class or function — the TS companion returns ``None`` for containing
|
||||
symbol.
|
||||
"""
|
||||
result = language_server.request_containing_symbol(TS_FILE, 0, 0)
|
||||
assert result is None, f"Expected None at (0,0), got: {result!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_references_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Position (0, 0) is on the ``import`` keyword — no references."""
|
||||
result = language_server.request_references(TS_FILE, 0, 0)
|
||||
assert result == [], f"Expected [] at (0,0), got: {result!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_definition_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Position (0, 0) is on the ``import`` keyword — no definition."""
|
||||
result = language_server.request_definition(TS_FILE, 0, 0)
|
||||
assert result == [], f"Expected [] at (0,0), got: {result!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_template_position_no_containing_symbol(self, language_server: SolidLanguageServer) -> None:
|
||||
"""An Angular template has no class/function containers; the HTML
|
||||
companion's documentSymbol provides element symbols only.
|
||||
"""
|
||||
# (1, 4) lands inside <h1>{{ title() | exclaim }}</h1> on line 1.
|
||||
result = language_server.request_containing_symbol(TEMPLATE_FILE, 1, 4)
|
||||
assert result is None, f"Expected None inside template, got: {result!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_containing_symbol_inside_class_body_returns_class(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Lines 5 and 10 of app.component.ts are inside the AppComponent class
|
||||
body. The TS companion correctly reports AppComponent as the
|
||||
containing symbol.
|
||||
"""
|
||||
for line in (5, 10):
|
||||
result = language_server.request_containing_symbol(TS_FILE, line, 0)
|
||||
assert isinstance(result, dict), f"Line {line}: expected dict, got {result!r}"
|
||||
assert result.get("name") == "AppComponent", f"Line {line}: expected 'AppComponent', got {result.get('name')!r}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_containing_symbol_outside_class_body_is_none(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Line 0 (import) and line 15 (blank/whitespace inside file but
|
||||
outside any symbol's range, depending on file structure) report no
|
||||
containing symbol.
|
||||
"""
|
||||
for line in (0,):
|
||||
result = language_server.request_containing_symbol(TS_FILE, line, 0)
|
||||
assert result is None, f"Line {line}: expected None, got {result!r}"
|
||||
|
||||
|
||||
class TestAngularReferenceEdgeCases:
|
||||
"""Edge cases for the SolidLanguageServer reference helpers (built on top of LSP)."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_referencing_symbols_at_invalid_position_raises(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Unlike ``request_references`` (which returns ``[]``),
|
||||
``request_referencing_symbols`` validates more strictly and surfaces
|
||||
the underlying TS server error as a ``SolidLSPException``.
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
# Windows TS server is known to swallow these — keep platform-specific
|
||||
# contract minimal here since we can't run Windows in dev.
|
||||
result = list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False))
|
||||
assert result == [], f"Expected [] on Windows, got: {result!r}"
|
||||
return
|
||||
with pytest.raises(SolidLSPException) as exc_info:
|
||||
list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False))
|
||||
assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value), (
|
||||
f"Unexpected exception message: {exc_info.value}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
|
||||
def test_defining_symbol_at_invalid_position_returns_none(self, language_server: SolidLanguageServer) -> None:
|
||||
"""``request_defining_symbol`` short-circuits when no definition is
|
||||
found and returns None — no exception, even for negative positions.
|
||||
"""
|
||||
result = language_server.request_defining_symbol(TS_FILE, -1, -1)
|
||||
assert result is None, f"Expected None, got: {result!r}"
|
||||
|
||||
|
||||
class TestAngularCoreProbe:
|
||||
"""Unit tests for the monorepo-aware ``_find_angular_core_install`` walk.
|
||||
|
||||
These don't spin up the Angular LS — they exercise the pure-Python
|
||||
filesystem walker against synthetic node_modules layouts.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _walk(repo_path: str) -> str | None:
|
||||
"""Invoke the bound method against a minimal stub carrying only
|
||||
``repository_root_path``.
|
||||
"""
|
||||
from solidlsp.language_servers.angular_language_server import AngularLanguageServer
|
||||
|
||||
stub = type("Stub", (), {"repository_root_path": repo_path})()
|
||||
return AngularLanguageServer._find_angular_core_install(stub) # type: ignore[arg-type]
|
||||
|
||||
def test_finds_core_at_project_root(self, tmp_path) -> None:
|
||||
core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
|
||||
core_pkg.parent.mkdir(parents=True)
|
||||
core_pkg.write_text("{}")
|
||||
assert self._walk(str(tmp_path)) == str(core_pkg)
|
||||
|
||||
def test_finds_hoisted_core_in_workspace_parent(self, tmp_path) -> None:
|
||||
"""Nx / yarn-workspaces layout: ``node_modules`` is hoisted to the
|
||||
workspace root and the activated sub-package has none of its own.
|
||||
"""
|
||||
sub_pkg = tmp_path / "packages" / "app"
|
||||
sub_pkg.mkdir(parents=True)
|
||||
core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
|
||||
core_pkg.parent.mkdir(parents=True)
|
||||
core_pkg.write_text("{}")
|
||||
# Workspace root marker — the walker stops here.
|
||||
(tmp_path / "package.json").write_text('{"workspaces": ["packages/*"]}')
|
||||
|
||||
assert self._walk(str(sub_pkg)) == str(core_pkg)
|
||||
|
||||
def test_returns_none_when_not_installed(self, tmp_path) -> None:
|
||||
"""No node_modules anywhere — walker exhausts and returns None."""
|
||||
sub_pkg = tmp_path / "packages" / "app"
|
||||
sub_pkg.mkdir(parents=True)
|
||||
assert self._walk(str(sub_pkg)) is None
|
||||
|
||||
def test_workspace_root_stops_walk(self, tmp_path) -> None:
|
||||
"""If a ``package.json`` declares ``workspaces`` we should not walk past
|
||||
it — the workspace root is the canonical install location even when no
|
||||
``@angular/core`` is present there.
|
||||
"""
|
||||
# Plant a misleading sibling install ABOVE the workspace root that the
|
||||
# walker must NOT find.
|
||||
sibling_core = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
|
||||
sibling_core.parent.mkdir(parents=True)
|
||||
sibling_core.write_text("{}")
|
||||
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
(workspace / "package.json").write_text('{"workspaces": ["packages/*"]}')
|
||||
sub_pkg = workspace / "packages" / "app"
|
||||
sub_pkg.mkdir(parents=True)
|
||||
|
||||
assert self._walk(str(sub_pkg)) is None
|
||||
|
||||
|
||||
class TestAngularStartupCleanup:
|
||||
"""Regression: companion processes (TS + HTML) must not leak when ngserver
|
||||
fails partway through ``_start_server``.
|
||||
|
||||
Until the cleanup wrapper landed, an exception during ngserver init left
|
||||
the two companion Node processes orphaned because the parent constructor
|
||||
never returned a handle on which the caller could invoke ``stop()``.
|
||||
"""
|
||||
|
||||
def test_companions_cleaned_up_on_initialize_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
if IS_WINDOWS:
|
||||
pytest.skip("psutil child enumeration is flaky under Windows CI")
|
||||
|
||||
import psutil
|
||||
|
||||
ls = _create_ls(Language.ANGULAR)
|
||||
my_proc = psutil.Process(os.getpid())
|
||||
children_before = {p.pid for p in my_proc.children(recursive=True)}
|
||||
|
||||
def boom(*_args: object, **_kwargs: object) -> dict:
|
||||
raise RuntimeError("simulated ngserver init failure")
|
||||
|
||||
# _get_initialize_params runs after both companions and ngserver have
|
||||
# been spawned — exactly the failure window the cleanup wrapper
|
||||
# protects against.
|
||||
monkeypatch.setattr(ls, "_get_initialize_params", boom)
|
||||
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="simulated ngserver init"):
|
||||
ls.start()
|
||||
|
||||
assert ls._ts_server is None, "TS companion was not cleared after startup failure"
|
||||
assert ls._html_server is None, "HTML companion was not cleared after startup failure"
|
||||
|
||||
# Allow a brief grace period for the OS to reap the spawned Node
|
||||
# processes (and grandchildren — typescript-language-server forks
|
||||
# tsserver).
|
||||
deadline = time.monotonic() + 5.0
|
||||
new_children: set[int] = set()
|
||||
while time.monotonic() < deadline:
|
||||
children_after = {p.pid for p in my_proc.children(recursive=True)}
|
||||
new_children = children_after - children_before
|
||||
if not new_children:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
assert not new_children, f"Companion processes leaked after startup failure: {sorted(new_children)}"
|
||||
finally:
|
||||
# Best-effort: if anything is still around (e.g. ngserver itself,
|
||||
# which lives outside the cleanup contract), shut it down so the
|
||||
# test session doesn't leave orphans.
|
||||
try:
|
||||
ls.stop(shutdown_timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
|
||||
@@ -5,6 +7,16 @@ from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
|
||||
PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY]
|
||||
|
||||
|
||||
def read_repo_file(language_server: SolidLanguageServer, relative_path: str) -> str:
|
||||
"""Read the text content of ``relative_path`` resolved against the LS's repository root.
|
||||
|
||||
Convenience for test code that needs to feed file content to
|
||||
:func:`serena.util.text_utils.find_text_coordinates`.
|
||||
"""
|
||||
abs_path = Path(language_server.language_server.repository_root_path) / relative_path
|
||||
return abs_path.read_text()
|
||||
|
||||
|
||||
def is_diagnostics_test_file(relative_path: str) -> bool:
|
||||
normalized_path = relative_path.replace("\\", "/")
|
||||
filename = normalized_path.rsplit("/", 1)[-1].lower()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Basic integration tests for the HTML language server.
|
||||
|
||||
The HTML LSP (vscode-html-language-server) provides in-file document symbols
|
||||
based on the element tree. Cross-file navigation (definition / references) is
|
||||
not meaningful for HTML and is therefore not tested here.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from test.solidlsp.conftest import request_all_symbols
|
||||
|
||||
|
||||
@pytest.mark.html
|
||||
class TestHtmlLanguageServerBasics:
|
||||
"""Smoke + symbol tests for the HTML language server."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.HTML], indirect=True)
|
||||
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
|
||||
assert language_server.is_running()
|
||||
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
|
||||
def test_index_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The HTML LSP exposes elements/IDs as document symbols."""
|
||||
all_symbols, _ = language_server.request_document_symbols("index.html").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
|
||||
# vscode-html-language-server reports elements by tag name and emits an entry
|
||||
# per element with an id attribute. Names contain the id like "header#page-header"
|
||||
# or just the tag like "head" for elements without an id; we check both forms.
|
||||
joined = " | ".join(names)
|
||||
for expected_id in ("page-header", "site-title", "main-nav", "section-features", "feature-list", "page-footer"):
|
||||
assert expected_id in joined, f"Expected id '{expected_id}' to appear in HTML symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
|
||||
def test_about_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols, _ = language_server.request_document_symbols("about.html").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
joined = " | ".join(names)
|
||||
for expected_id in ("page-header", "about-title", "main-nav", "about-article"):
|
||||
assert expected_id in joined, f"Expected id '{expected_id}' to appear in HTML symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
|
||||
def test_full_symbol_tree_includes_both_files(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols = request_all_symbols(language_server)
|
||||
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
|
||||
assert "index.html" in relative_paths
|
||||
assert "about.html" in relative_paths
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Diagnostics smoke test for the HTML language server.
|
||||
|
||||
``vscode-html-language-server`` does not validate HTML structure itself — upstream
|
||||
``vscode-html-languageservice`` only forwards validation to embedded sub-services
|
||||
(CSS/JS in ``<style>`` / ``<script>`` blocks), and those aren't wired up in
|
||||
standalone mode. So a malformed-HTML fixture would yield zero diagnostics regardless.
|
||||
|
||||
Asserting "at least one diagnostic" would be a false positive waiting to happen.
|
||||
Instead, this test verifies the diagnostics endpoint is reachable end-to-end and
|
||||
the framework's pull→publish fallback handles the empty response correctly — i.e.
|
||||
it returns a list, not an error. That alone catches LS-crash regressions on the
|
||||
diagnostic request, which is the practical purpose for this LS.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
|
||||
@pytest.mark.html
|
||||
class TestHtmlDiagnostics:
|
||||
@pytest.mark.parametrize("language_server", [Language.HTML], indirect=True)
|
||||
def test_diagnostics_endpoint_returns_list(self, language_server: SolidLanguageServer) -> None:
|
||||
diagnostics = language_server.request_text_document_diagnostics("diagnostics_sample.html", min_severity=1)
|
||||
assert isinstance(diagnostics, list), diagnostics
|
||||
@@ -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)}"
|
||||
Whitespace-only changes.
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
Basic integration tests for the SCSS language server (Some Sass).
|
||||
|
||||
Some Sass provides full @use/@forward workspace navigation, so this suite
|
||||
exercises both in-file document symbols and cross-file go-to-definition for
|
||||
variables and mixins.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from serena.util.text_utils import find_text_coordinates
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_types import SymbolKind
|
||||
from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes
|
||||
from test.solidlsp.conftest import read_repo_file, request_all_symbols
|
||||
|
||||
|
||||
@pytest.mark.scss
|
||||
class TestScssLanguageServerBasics:
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
@pytest.mark.parametrize("repo_path", [Language.SCSS], indirect=True)
|
||||
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
|
||||
assert language_server.is_running()
|
||||
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_variables_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols, _ = language_server.request_document_symbols("_variables.scss").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
for var in ("$color-primary", "$color-secondary", "$color-text", "$space-md", "$space-lg"):
|
||||
assert var in names, f"Expected variable {var} to appear in SCSS symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_mixins_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols, _ = language_server.request_document_symbols("_mixins.scss").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
# Some Sass surfaces @mixin and @function entries; names may be bare ("card-surface")
|
||||
# or include the @-keyword. Check substring inclusion for robustness.
|
||||
joined = " | ".join(names)
|
||||
for expected in ("card-surface", "focus-ring", "rem"):
|
||||
assert expected in joined, f"Expected '{expected}' to appear in SCSS mixin symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_buttons_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols, _ = language_server.request_document_symbols("buttons.scss").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
joined = " | ".join(names)
|
||||
for selector in (".button", ".button-primary", ".button-secondary"):
|
||||
assert selector in joined, f"Expected selector '{selector}' to appear in SCSS symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_cross_file_definition_variable(self, language_server: SolidLanguageServer) -> None:
|
||||
"""`vars.$color-text` in buttons.scss must resolve into _variables.scss."""
|
||||
path = "buttons.scss"
|
||||
needle = "$color-text"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
# Cursor inside the variable identifier.
|
||||
definitions = language_server.request_definition(path, line, col + 2)
|
||||
assert definitions, f"Expected non-empty cross-file definition list for vars.$color-text, got {definitions}"
|
||||
target_uris = [d["uri"] for d in definitions]
|
||||
assert any(uri.endswith("_variables.scss") for uri in target_uris), (
|
||||
f"Expected definition to resolve into _variables.scss, got URIs: {target_uris}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_cross_file_definition_mixin(self, language_server: SolidLanguageServer) -> None:
|
||||
"""`mix.card-surface` in buttons.scss must resolve into _mixins.scss."""
|
||||
path = "buttons.scss"
|
||||
needle = "card-surface"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
definitions = language_server.request_definition(path, line, col + 2)
|
||||
assert definitions, f"Expected non-empty cross-file definition list for mix.card-surface, got {definitions}"
|
||||
target_uris = [d["uri"] for d in definitions]
|
||||
assert any(uri.endswith("_mixins.scss") for uri in target_uris), (
|
||||
f"Expected definition to resolve into _mixins.scss, got URIs: {target_uris}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_cross_file_definition_function(self, language_server: SolidLanguageServer) -> None:
|
||||
"""`mix.rem(16)` in main.scss must resolve into _mixins.scss (an @function)."""
|
||||
path = "main.scss"
|
||||
needle = "mix.rem"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
# Cursor inside the function identifier (skip the `mix.` prefix).
|
||||
definitions = language_server.request_definition(path, line, col + 5)
|
||||
assert definitions, f"Expected non-empty cross-file definition list for mix.rem, got {definitions}"
|
||||
target_uris = [d["uri"] for d in definitions]
|
||||
assert any(uri.endswith("_mixins.scss") for uri in target_uris), (
|
||||
f"Expected definition to resolve into _mixins.scss, got URIs: {target_uris}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols = request_all_symbols(language_server)
|
||||
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
|
||||
for f in ("_variables.scss", "_mixins.scss", "buttons.scss", "main.scss"):
|
||||
assert f in relative_paths, f"Expected {f} to appear in symbol tree"
|
||||
|
||||
|
||||
@pytest.mark.scss
|
||||
class TestScssReferences:
|
||||
"""Find-references for symbols re-exported via @use across files."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_mixin_references_span_files(self, language_server: SolidLanguageServer) -> None:
|
||||
"""References for ``card-surface`` must include both ``buttons.scss``
|
||||
(`.button`, `.button-primary`, `.button-secondary` all `@include` it) and
|
||||
``main.scss`` (`.panel` `@include`s it).
|
||||
|
||||
Some Sass returns no references when the request originates on the
|
||||
``@mixin`` declaration itself, so we probe from a usage site in
|
||||
``buttons.scss`` — that's also closer to how an editor user invokes
|
||||
find-references in practice.
|
||||
"""
|
||||
path = "buttons.scss"
|
||||
needle = "card-surface"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
refs = language_server.request_references(path, line, col + 2)
|
||||
ref_paths = {r.get("relativePath", "") for r in refs}
|
||||
assert any(p.endswith("buttons.scss") for p in ref_paths), (
|
||||
f"Expected card-surface references to include buttons.scss, got: {ref_paths}"
|
||||
)
|
||||
assert any(p.endswith("main.scss") for p in ref_paths), f"Expected card-surface references to include main.scss, got: {ref_paths}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_variable_references_span_files(self, language_server: SolidLanguageServer) -> None:
|
||||
"""`$color-primary` is read in ``buttons.scss`` (`.button-primary` background)
|
||||
and ``_mixins.scss`` (default value of ``focus-ring``); references invoked
|
||||
from the ``buttons.scss`` usage site must include both files.
|
||||
"""
|
||||
path = "buttons.scss"
|
||||
needle = "$color-primary"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
refs = language_server.request_references(path, line, col + 2)
|
||||
ref_paths = {r.get("relativePath", "") for r in refs}
|
||||
assert any(p.endswith("buttons.scss") for p in ref_paths), (
|
||||
f"Expected $color-primary references to include buttons.scss, got: {ref_paths}"
|
||||
)
|
||||
assert any(p.endswith("_mixins.scss") for p in ref_paths), (
|
||||
f"Expected $color-primary references to include _mixins.scss (default param of focus-ring), got: {ref_paths}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.scss
|
||||
class TestScssForward:
|
||||
"""`@forward` re-exports a module; consumers should reach forwarded symbols."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_forwarded_buttons_appear_in_workspace(self, language_server: SolidLanguageServer) -> None:
|
||||
"""``main.scss`` does ``@forward "buttons"``; the workspace symbol tree must
|
||||
still include ``buttons.scss`` selectors so consumers of `main` can navigate.
|
||||
"""
|
||||
all_symbols = request_all_symbols(language_server)
|
||||
button_symbols = [s for s in all_symbols if s.get("location", {}).get("relativePath") == "buttons.scss"]
|
||||
names = [s["name"] for s in button_symbols]
|
||||
joined = " | ".join(names)
|
||||
assert ".button" in joined, f"Expected .button selector to remain reachable via @forward, got: {names}"
|
||||
|
||||
|
||||
@pytest.mark.scss
|
||||
class TestScssHover:
|
||||
"""Some Sass returns rich hover content (SassDoc / value preview)."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_hover_on_variable_use(self, language_server: SolidLanguageServer) -> None:
|
||||
path = "buttons.scss"
|
||||
needle = "$color-text"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
hover = language_server.request_hover(path, line, col + 2)
|
||||
assert hover is not None, f"Expected hover info for $color-text in {path}, got None"
|
||||
contents = hover.get("contents")
|
||||
assert contents, f"Expected non-empty hover contents, got: {hover}"
|
||||
text = contents["value"] if isinstance(contents, dict) else str(contents)
|
||||
assert "color-text" in text, f"Expected '$color-text' or its value in hover text, got: {text}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_hover_on_mixin_call(self, language_server: SolidLanguageServer) -> None:
|
||||
path = "buttons.scss"
|
||||
needle = "card-surface"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
hover = language_server.request_hover(path, line, col + 2)
|
||||
assert hover is not None, f"Expected hover info for card-surface in {path}, got None"
|
||||
contents = hover.get("contents")
|
||||
assert contents, f"Expected non-empty hover contents, got: {hover}"
|
||||
text = contents["value"] if isinstance(contents, dict) else str(contents)
|
||||
assert "card-surface" in text, f"Expected 'card-surface' in hover text, got: {text}"
|
||||
|
||||
|
||||
@pytest.mark.scss
|
||||
class TestScssCompletions:
|
||||
"""Completions after a namespaced @use prefix should list re-exported members."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_completion_after_namespace_dot(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Completion immediately after `vars.` in buttons.scss must include the
|
||||
variables defined in _variables.scss (e.g. ``$color-primary``).
|
||||
"""
|
||||
path = "buttons.scss"
|
||||
# `color: vars.$color-text;` — invoke completion at the `.` position so the
|
||||
# LSP sees the namespace prefix and offers its members.
|
||||
needle = "vars.$color-text"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
# Position the cursor right after the `.` (4 chars: 'v','a','r','s','.' -> idx 5)
|
||||
completions = language_server.request_completions(path, line, col + 5)
|
||||
labels = {c.get("completionText", "") for c in completions}
|
||||
# Different Some Sass releases label vars with or without leading $; accept both.
|
||||
joined = " | ".join(sorted(labels))
|
||||
assert any("color-primary" in label for label in labels), f"Expected $color-primary completion after `vars.` prefix, got: {joined}"
|
||||
|
||||
|
||||
@pytest.mark.scss
|
||||
class TestScssSymbolKinds:
|
||||
"""Validate that Some Sass classifies SCSS symbols with sensible LSP kinds."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_variable_symbol_kind(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols, _ = language_server.request_document_symbols("_variables.scss").get_all_symbols_and_roots()
|
||||
by_name = {s["name"]: s for s in all_symbols}
|
||||
assert "$color-primary" in by_name, f"Variable not in symbol list: {list(by_name)}"
|
||||
kind = SymbolKind(by_name["$color-primary"]["kind"])
|
||||
assert kind in (SymbolKind.Variable, SymbolKind.Constant, SymbolKind.Property), (
|
||||
f"Expected $color-primary to be Variable/Constant/Property, got {kind.name}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_mixin_and_function_symbol_kinds(self, language_server: SolidLanguageServer) -> None:
|
||||
all_symbols, _ = language_server.request_document_symbols("_mixins.scss").get_all_symbols_and_roots()
|
||||
|
||||
# Names may include the @-keyword (e.g. "@mixin card-surface") or be bare;
|
||||
# match by substring.
|
||||
def find_one(needle: str) -> dict:
|
||||
matches = [s for s in all_symbols if needle in s["name"]]
|
||||
assert matches, f"No symbol matched '{needle}' in {[s['name'] for s in all_symbols]}"
|
||||
return matches[0]
|
||||
|
||||
mixin = find_one("card-surface")
|
||||
func = find_one("rem")
|
||||
# Some Sass historically reports mixins/functions as Method or Function.
|
||||
# Accept either, but reject obviously-wrong kinds (e.g. Variable/Class).
|
||||
callable_kinds = {SymbolKind.Method, SymbolKind.Function}
|
||||
assert SymbolKind(mixin["kind"]) in callable_kinds, (
|
||||
f"Expected card-surface kind in {{Method, Function}}, got {SymbolKind(mixin['kind']).name}"
|
||||
)
|
||||
assert SymbolKind(func["kind"]) in callable_kinds, f"Expected rem kind in {{Method, Function}}, got {SymbolKind(func['kind']).name}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_completion_kind_is_meaningful(self, language_server: SolidLanguageServer) -> None:
|
||||
"""A `$variable` completion must come back with a meaningful kind. Some Sass
|
||||
tags color-valued variables as ``Color`` (so editors render swatches) and
|
||||
non-color variables as ``Variable``/``Property``/``Constant`` — accept any
|
||||
of these but reject generic ``Text`` which would indicate the LSP failed
|
||||
to classify the completion.
|
||||
"""
|
||||
path = "buttons.scss"
|
||||
needle = "vars.$color-text"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
completions = language_server.request_completions(path, line, col + 5)
|
||||
var_items = [c for c in completions if "color-primary" in c.get("completionText", "")]
|
||||
assert var_items, "no $color-primary completion item found"
|
||||
accepted = {
|
||||
LSPTypes.CompletionItemKind.Variable,
|
||||
LSPTypes.CompletionItemKind.Property,
|
||||
LSPTypes.CompletionItemKind.Constant,
|
||||
LSPTypes.CompletionItemKind.Color,
|
||||
LSPTypes.CompletionItemKind.Value,
|
||||
}
|
||||
kinds = {LSPTypes.CompletionItemKind(c["kind"]).name for c in var_items}
|
||||
assert any(LSPTypes.CompletionItemKind(c["kind"]) in accepted for c in var_items), (
|
||||
f"Expected variable-like CompletionItemKind for $color-primary, got: {kinds}"
|
||||
)
|
||||
|
||||
|
||||
# --- Plain CSS via Some Sass --------------------------------------------------
|
||||
#
|
||||
# Some Sass advertises the LSP ``css`` languageId as a first-class consumer (see
|
||||
# ``packages/language-services/src/language-services-types.ts:LanguageConfiguration``)
|
||||
# and dispatches per-feature handlers via the ``somesass.css.*.enabled`` toggles —
|
||||
# all of which default to ``false`` upstream and which Serena flips on at init.
|
||||
# These tests verify the routing actually works end-to-end against a small CSS
|
||||
# fixture under ``css/`` in the same SCSS test repo.
|
||||
|
||||
|
||||
@pytest.mark.scss
|
||||
class TestSomeSassWithPlainCss:
|
||||
"""``Language.SCSS`` also handles plain ``.css`` via ``some-sass-language-server``."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_main_css_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Each top-level rule selector in ``main.css`` must surface as a document symbol."""
|
||||
all_symbols, _ = language_server.request_document_symbols("css/main.css").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
joined = " | ".join(names)
|
||||
for selector in ("body", "#page-header", "#site-title", ".button", ".button-primary", ".button-secondary"):
|
||||
assert selector in joined, f"Expected selector '{selector}' to appear in CSS symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_theme_css_document_symbols(self, language_server: SolidLanguageServer) -> None:
|
||||
"""``theme.css`` contains a single ``:root`` block; the LS must report it as a symbol."""
|
||||
all_symbols, _ = language_server.request_document_symbols("css/theme.css").get_all_symbols_and_roots()
|
||||
names = [s["name"] for s in all_symbols]
|
||||
joined = " | ".join(names)
|
||||
assert ":root" in joined, f"Expected ':root' selector to appear in CSS symbols: {names}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_full_symbol_tree_includes_css_files(self, language_server: SolidLanguageServer) -> None:
|
||||
"""The ``.css`` files alongside the SCSS workspace must populate the workspace symbol tree."""
|
||||
all_symbols = request_all_symbols(language_server)
|
||||
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
|
||||
# `relativePath` uses OS-native separators (cf. test_symbol_retrieval.py),
|
||||
# so build expected paths via os.path.join to keep this test cross-platform.
|
||||
for f in (os.path.join("css", "main.css"), os.path.join("css", "reset.css"), os.path.join("css", "theme.css")):
|
||||
assert f in relative_paths, f"Expected {f} to appear in symbol tree"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_hover_on_css_property(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Hover on a CSS property name must produce non-empty MDN-backed content
|
||||
(Some Sass forwards ``vscode-css-languageservice``'s property reference data).
|
||||
"""
|
||||
path = "css/main.css"
|
||||
needle = "background-color"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
hover = language_server.request_hover(path, line, col + 2)
|
||||
assert hover is not None, f"Expected hover info for background-color in {path}, got None"
|
||||
contents = hover.get("contents")
|
||||
assert contents, f"Expected non-empty hover contents, got: {hover}"
|
||||
text = contents["value"] if isinstance(contents, dict) else str(contents)
|
||||
assert "background" in text.lower(), f"Expected hover text to mention 'background', got: {text}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_property_completion_in_css_rule(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Inside a CSS rule body the LS must offer standard property names —
|
||||
proves ``somesass.css.completion.enabled = true`` is being honoured.
|
||||
"""
|
||||
path = "css/main.css"
|
||||
needle = "padding:"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
completions = language_server.request_completions(path, line, col)
|
||||
labels = {c.get("completionText", "") for c in completions}
|
||||
assert any(label in labels for label in ("padding", "margin", "color", "border")), (
|
||||
f"Expected at least one common CSS property name in completions, got sample: {sorted(labels)[:20]}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_cross_file_completion_for_css_custom_property(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Completion inside a ``var(...)`` call in ``main.css`` must surface the
|
||||
``--color-*`` custom properties declared in ``theme.css``.
|
||||
|
||||
This is the single test that empirically validates Some Sass crosses file
|
||||
boundaries for plain CSS — without ``somesass.css.completion.enabled = true``
|
||||
the request would short-circuit to ``null`` at the handler entrypoint.
|
||||
|
||||
Note: ``vscode-css-languageservice`` (Some Sass' CSS engine) deliberately
|
||||
does NOT implement go-to-definition for CSS custom properties yet — see
|
||||
microsoft/vscode-css-languageservice#734. Completion is the closest
|
||||
upstream-supported API that proves cross-file awareness.
|
||||
"""
|
||||
path = "css/main.css"
|
||||
# `` color: var(--color-text);`` — invoke completion right after the
|
||||
# leading dashes so the LS treats the request as a partial custom-property
|
||||
# identifier and offers matching declarations from the workspace.
|
||||
needle = "var(--color-text"
|
||||
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
|
||||
assert coords is not None, f"Could not find {needle!r} in {path}"
|
||||
line, col = coords.line, coords.col
|
||||
cursor_col = col + len("var(--")
|
||||
completions = language_server.request_completions(path, line, cursor_col)
|
||||
labels = {c.get("completionText", "") for c in completions}
|
||||
joined = " | ".join(sorted(labels))
|
||||
assert any("--color-primary" in label for label in labels), (
|
||||
f"Expected --color-primary completion (declared cross-file in theme.css), got: {joined}"
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Diagnostics tests for the SCSS / Sass / CSS language server (Some Sass).
|
||||
|
||||
Two sources are exercised — the SCSS parser (undefined ``$variable``) and the
|
||||
plain-CSS parser (syntax error inside a rule body). ``somesass.css.diagnostics.enabled``
|
||||
is flipped on at initialization so the .css path is not gated off; lint diagnostics
|
||||
are deliberately left disabled (the upstream rules are opinionated about vendor
|
||||
prefixes / empty rules / etc.) so only syntax-level errors surface here.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from test.solidlsp.util.diagnostics import assert_file_diagnostics
|
||||
|
||||
|
||||
@pytest.mark.scss
|
||||
class TestScssDiagnostics:
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_scss_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
|
||||
assert_file_diagnostics(
|
||||
language_server,
|
||||
"diagnostics_sample.scss",
|
||||
(),
|
||||
min_count=1,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SCSS], indirect=True)
|
||||
def test_plain_css_file_diagnostics(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Plain ``.css`` diagnostics flow through the same Some Sass server.
|
||||
|
||||
Without ``somesass.css.diagnostics.enabled = true`` (which Serena pushes via
|
||||
initializationOptions), this would short-circuit to an empty list at the
|
||||
handler entrypoint.
|
||||
"""
|
||||
assert_file_diagnostics(
|
||||
language_server,
|
||||
"css/diagnostics_sample.css",
|
||||
(),
|
||||
min_count=1,
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
Reference in new issue
Block a user