diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 1d2b79a6..a17b8148 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2772bef6..fb76d126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ Status of the `main` branch. Changes prior to the next official version change w * Language Servers: - Elixir (`elixir-tools/next-ls`): Fix deadlock in monorepo projects where `mix.exs` lives in a subdirectory. The server now searches immediate subdirectories when no `mix.exs` is found at the repository root. #1444 - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 + - Add **Angular** (experimental) via a dual-server architecture: `@angular/language-server` (ngserver) handles standalone `.html` template files, while a companion `typescript-language-server` with `@angular/language-service` loaded as a tsserver plugin handles all `.ts` operations including inline templates. Provides type-aware navigation between templates and component classes. Requires Node.js, npm, and `@angular/core` installed in the project (`npm install` in the project root). Subsumes `typescript`+`html` for `.ts`/`.html` files when active; SCSS is not subsumed. + - Add **HTML** (experimental) using `vscode-html-language-server` from the `vscode-langservers-extracted` npm package. Provides in-file element/id symbols via documentSymbol; cross-file references are not meaningful for HTML. Also used as a companion server by the Angular LS for plain HTML documentSymbol support. + - Add **SCSS / Sass / CSS** (experimental) using [some-sass-language-server](https://github.com/wkillerud/some-sass). Handles `.scss`, `.sass`, and `.css` through one server, with full `@use`/`@forward` workspace-wide go-to-definition and find-references for variables, mixins, and functions across Sass files. The `.css` path uses the same `vscode-css-languageservice` engine that powers the standalone CSS LS; CSS feature toggles default off upstream and are flipped on at startup so symbols, hover, completion, and syntax-level diagnostics work for plain CSS as well. # v1.2.0 (2026-04-27) diff --git a/README.md b/README.md index de04fa91..b0909f06 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index dff0a228..02a37432 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -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) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index e5c28749..99388485 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -227,8 +227,13 @@ ls_specific_settings: This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`, and by some additional wrappers that explicitly expose `ls_path`. -Common examples include: `ansible`, `bash`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `kotlin`, `lean4`, `luau`, `markdown`, `php`, -`php_phpactor`, `python`, `rust`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`. +Common examples include: `ansible`, `bash`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`, +`php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`. + +Note: `angular` does **not** support `ls_path` — the Angular language server is part of a multi-process orchestration +(`ngserver` plus a companion TypeScript language server with the `@angular/language-service` plugin and an HTML +companion) where the dependency layout matters; use the version overrides documented in the Angular section below +to pin specific releases of the bundled stack. If a language server supports `ls_path`, setting it bypasses Serena's managed download or install for that server. In that case, any server-specific version or registry settings only apply when `ls_path` is not set. @@ -243,6 +248,36 @@ Supported settings: |---|---|---| | `al_extension_version` | `18.0.2242655` | Override the AL VS Code extension version Serena downloads from the VS Code Marketplace. | +#### Angular + +Serena uses `@angular/language-server` (`ngserver`) for the `angular` language key, orchestrated together with a +companion `typescript-language-server` (with `@angular/language-service` loaded as a tsserver plugin) and a +companion `vscode-html-language-server` for `.html` `documentSymbol`. This is an **experimental** language and +must be explicitly listed in `project.yml`; it is not auto-detected. + +**Project requirements:** + +- The project itself must have `@angular/core` installed (i.e. `npm install` must have been run in the project root, + or in a workspace root above it for monorepo layouts). Without it, `ngserver` reports every file as "not in an + Angular project" and template-aware features silently return empty. +- A `tsconfig.json` must be reachable at or above any opened `.ts` file. +- Do **not** also list `typescript` or `html` in `languages` when `angular` is active — Angular subsumes both + for `.ts` / `.html` files. SCSS is **not** subsumed; list `scss` separately if needed. + +Supported settings: + +| Setting | Default | Description | +|---|---|---| +| `angular_language_server_version` | `21.2.10` | Override the bundled `@angular/language-server` npm package version Serena installs. | +| `angular_language_service_version` | `21.2.10` | Override the bundled `@angular/language-service` tsserver plugin version. | +| `typescript_version` | `5.9.3` | Override the bundled `typescript` npm package version. Falls back to `ls_specific_settings.typescript.typescript_version` if unset. | +| `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` version. Falls back to `ls_specific_settings.typescript.typescript_language_server_version` if unset. | +| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. Falls back to `ls_specific_settings.typescript.npm_registry` if unset. | + +Notes: +- The HTML companion (`vscode-html-language-server`) is configured via `ls_specific_settings.html` — see the HTML section below. +- `ls_path` is not supported (see note above the AL section). + #### Ansible Serena uses `@ansible/ansible-language-server` for the `ansible` language key. @@ -496,6 +531,25 @@ ls_specific_settings: renameSourceFolders: ["src", "lib"] ``` +#### HTML + +Serena uses `vscode-html-language-server` from Microsoft's `vscode-langservers-extracted` npm package for the +`html` language key. **Experimental** — must be explicitly listed in `project.yml`; not auto-detected. The HTML +LSP returns in-file element / id symbols via `documentSymbol`; cross-file `definition` / `references` are not +meaningful for HTML and are not exposed. + +This same language server is also used as a tertiary companion by the Angular language server (see the Angular +section), since `ngserver` does not implement `textDocument/documentSymbol` for `.html` files. + +Supported settings: + +| Setting | Default | Description | +|---|---|---| +| `ls_path` | managed install | Override the `vscode-html-language-server` executable path. | +| `vscode_langservers_package` | `vscode-langservers-extracted` | npm package providing the binary. Set to `@t1ckbase/vscode-langservers-extracted` (or any other source) to use the actively-maintained 2026 fork. | +| `vscode_langservers_version` | `4.10.0` | Override the npm package version Serena installs when `ls_path` is not set. | +| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | + #### Java (`eclipse.jdt.ls`) Java support has two installation modes: @@ -790,6 +844,26 @@ Supported settings: | `on_stale_lock` | `auto-clean` | How Serena handles stale Metals H2 database locks. Supported values: `auto-clean`, `warn`, `fail`. | | `log_multi_instance_notice` | `true` | Log a notice when another Metals instance is detected. | +#### SCSS / Sass / CSS + +Serena uses [`some-sass-language-server`](https://github.com/wkillerud/some-sass) for the `scss` language key. +**Experimental** — must be explicitly listed in `project.yml`; not auto-detected. Some Sass was chosen over the +generic `vscode-css-language-server` because it provides full workspace-wide `@use` / `@forward` go-to-definition +and find-references for variables, mixins, functions, and placeholders. + +Handles `.scss`, `.sass`, and `.css`. The three are dispatched by the LSP language id (`scss`, `sass`, `css`) and +share the same engine; CSS feature toggles default to off upstream and Serena flips them on at startup so that +plain CSS gets symbols, definitions, references, hover, and completion. Lint diagnostics are deliberately left +off (the rules are opinionated about vendor prefixes / empty rules / etc.); only syntax-level diagnostics surface. + +Supported settings: + +| Setting | Default | Description | +|---|---|---| +| `ls_path` | managed install | Override the `some-sass-language-server` executable path. | +| `some_sass_version` | `2.3.8` | Override the npm package version Serena installs when `ls_path` is not set. | +| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | + #### Solidity Serena uses `@nomicfoundation/solidity-language-server` for Solidity support. diff --git a/docs/02-usage/070_security.md b/docs/02-usage/070_security.md index 5a1d5668..3278a7fc 100644 --- a/docs/02-usage/070_security.md +++ b/docs/02-usage/070_security.md @@ -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` diff --git a/pyproject.toml b/pyproject.toml index 35c00d2d..f9a61d3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 18e4b75d..e5c877fe 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -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. diff --git a/src/solidlsp/language_servers/angular_language_server.py b/src/solidlsp/language_servers/angular_language_server.py new file mode 100644 index 00000000..03d7414e --- /dev/null +++ b/src/solidlsp/language_servers/angular_language_server.py @@ -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 (``
``, ````, ````…) + 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 + ) diff --git a/src/solidlsp/language_servers/some_sass_language_server.py b/src/solidlsp/language_servers/some_sass_language_server.py new file mode 100644 index 00000000..770b80ae --- /dev/null +++ b/src/solidlsp/language_servers/some_sass_language_server.py @@ -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() diff --git a/src/solidlsp/language_servers/vscode_html_language_server.py b/src/solidlsp/language_servers/vscode_html_language_server.py new file mode 100644 index 00000000..7b32fc08 --- /dev/null +++ b/src/solidlsp/language_servers/vscode_html_language_server.py @@ -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() diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index f99056ae..84d1ce8c 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -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: diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 1eb1295a..04febfd2 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -155,6 +155,33 @@ class Language(str, Enum): Must be explicitly specified in project.yml. Requires Node.js and npm. Requires ``ansible`` in PATH for full functionality. """ + HTML = "html" + """HTML language server (experimental) using vscode-html-language-server from + Microsoft's vscode-langservers-extracted npm package. Supports *.html and *.htm files. + Must be explicitly specified in project.yml. Requires Node.js and npm. + Note: HTML LSP provides in-file element/id symbols only; cross-file references + are not meaningful for HTML. Also used as a companion server by Angular LS for + plain HTML documentSymbol support. + """ + SCSS = "scss" + """SCSS / Sass / CSS language server (experimental) using some-sass-language-server + (https://github.com/wkillerud/some-sass). Handles *.scss, *.sass, and *.css. + Must be explicitly specified in project.yml. Requires Node.js and npm. + Provides full @use/@forward workspace navigation across SCSS files; CSS support + relies on the same vscode-css-languageservice engine and is enabled at startup + via the somesass.css.* feature toggles (which default to off upstream). + """ + ANGULAR = "angular" + """Angular Language Server (experimental) using the official @angular/language-server + (ngserver). Supports *.ts and *.html files (Angular templates can be external or inline). + Understands Angular template syntax (*ngIf, [prop], (event), {{ interpolation }}, + @if/@for blocks, etc.) and provides type-aware navigation between templates and + component classes — which the plain HTML and TypeScript LSPs cannot. + Requires Node.js, npm, and a valid Angular workspace (angular.json or Nx project.json + at the repository root). When activated, do not also enable typescript or html in + project.yml — Angular LS supersedes both for Angular projects. + Must be explicitly specified in project.yml. + """ @classmethod def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]: @@ -185,6 +212,9 @@ class Language(str, Enum): self.GROOVY, self.CPP_CCLS, self.SOLIDITY, + self.HTML, + self.SCSS, + self.ANGULAR, } def __str__(self) -> str: @@ -344,6 +374,22 @@ class Language(str, Enum): return FilenameMatcher("*.yaml", "*.yml") case self.MSL: return FilenameMatcher("*.mrc") + case self.HTML: + return FilenameMatcher("*.html", "*.htm") + case self.SCSS: + # *.css is handled by the same engine (vscode-css-languageservice) that powers + # Microsoft's CSS LS, so we route plain CSS through Some Sass too. The CSS feature + # toggles default off upstream and are flipped on at initialization time. + return FilenameMatcher("*.scss", "*.sass", "*.css") + case self.ANGULAR: + # Angular templates can be standalone .html files or inline templates + # within .ts component files; the dual-server architecture handles both. + # SCSS / styles are deliberately NOT subsumed — use Language.SCSS for those. + path_patterns = ["*.html", "*.htm"] + for prefix in ["c", "m", ""]: + for postfix in ["x", ""]: + path_patterns.append(f"*.{prefix}ts{postfix}") + return FilenameMatcher(*path_patterns) case _: raise ValueError(f"Unhandled language: {self}") @@ -575,6 +621,18 @@ class Language(str, Enum): from solidlsp.language_servers.msl_language_server import MslLanguageServer return MslLanguageServer + case self.HTML: + from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer + + return VsCodeHtmlLanguageServer + case self.SCSS: + from solidlsp.language_servers.some_sass_language_server import SomeSassLanguageServer + + return SomeSassLanguageServer + case self.ANGULAR: + from solidlsp.language_servers.angular_language_server import AngularLanguageServer + + return AngularLanguageServer case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/conftest.py b/test/conftest.py index 5d66bf78..aefd34ea 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -277,6 +277,9 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.PYTHON_TY: [pytest.mark.python], Language.RUST: [pytest.mark.rust], Language.TYPESCRIPT: [pytest.mark.typescript], + Language.ANGULAR: [pytest.mark.angular], + Language.HTML: [pytest.mark.html], + Language.SCSS: [pytest.mark.scss], } @@ -349,6 +352,7 @@ def languages_supporting_implementation(*languages: Language) -> list[Language]: _VERIFIED_IMPLEMENTATION_LANGUAGES = { + Language.ANGULAR, Language.CSHARP, Language.GO, Language.JAVA, diff --git a/test/resources/repos/angular/test_repo/.gitignore b/test/resources/repos/angular/test_repo/.gitignore new file mode 100644 index 00000000..11431c9b --- /dev/null +++ b/test/resources/repos/angular/test_repo/.gitignore @@ -0,0 +1,5 @@ +node_modules +.angular +dist +out-tsc +.angular-install.lock diff --git a/test/resources/repos/angular/test_repo/angular.json b/test/resources/repos/angular/test_repo/angular.json new file mode 100644 index 00000000..337e060f --- /dev/null +++ b/test/resources/repos/angular/test_repo/angular.json @@ -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" + } + } + } + } + } +} diff --git a/test/resources/repos/angular/test_repo/package-lock.json b/test/resources/repos/angular/test_repo/package-lock.json new file mode 100644 index 00000000..046eab74 --- /dev/null +++ b/test/resources/repos/angular/test_repo/package-lock.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" + } + } +} diff --git a/test/resources/repos/angular/test_repo/package.json b/test/resources/repos/angular/test_repo/package.json new file mode 100644 index 00000000..09e227d8 --- /dev/null +++ b/test/resources/repos/angular/test_repo/package.json @@ -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" + } +} diff --git a/test/resources/repos/angular/test_repo/src/app/app.component.html b/test/resources/repos/angular/test_repo/src/app/app.component.html new file mode 100644 index 00000000..b8498db3 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/app.component.html @@ -0,0 +1,21 @@ +
+

{{ title() | exclaim }}

+

{{ greeting() }}

+ + + + @if (items().length > 0) { +
    + @for (item of items(); track item) { + + } +
+ } @else { +

No items.

+ } +
diff --git a/test/resources/repos/angular/test_repo/src/app/app.component.ts b/test/resources/repos/angular/test_repo/src/app/app.component.ts new file mode 100644 index 00000000..25f22123 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/app.component.ts @@ -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(['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); + } +} diff --git a/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.html b/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.html new file mode 100644 index 00000000..c38be8d1 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.html @@ -0,0 +1,6 @@ + +
+

{{ undefinedSignal() }}

+
diff --git a/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.ts b/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.ts new file mode 100644 index 00000000..18583943 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/diagnostics_sample.ts @@ -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'; +} diff --git a/test/resources/repos/angular/test_repo/src/app/exclaim.pipe.ts b/test/resources/repos/angular/test_repo/src/app/exclaim.pipe.ts new file mode 100644 index 00000000..38e09f67 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/exclaim.pipe.ts @@ -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); + } +} diff --git a/test/resources/repos/angular/test_repo/src/app/greeter.interface.ts b/test/resources/repos/angular/test_repo/src/app/greeter.interface.ts new file mode 100644 index 00000000..5850224e --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/greeter.interface.ts @@ -0,0 +1,3 @@ +export interface Greeter { + greet(name?: string): string; +} diff --git a/test/resources/repos/angular/test_repo/src/app/greeting.service.ts b/test/resources/repos/angular/test_repo/src/app/greeting.service.ts new file mode 100644 index 00000000..86cdbf7b --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/greeting.service.ts @@ -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}!`; + } +} diff --git a/test/resources/repos/angular/test_repo/src/app/item-card.component.ts b/test/resources/repos/angular/test_repo/src/app/item-card.component.ts new file mode 100644 index 00000000..7af9f2af --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/app/item-card.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-item-card', + standalone: true, + template: ` +
  • + {{ label }} +
  • + `, +}) +export class ItemCardComponent { + @Input() label: string = ''; + + formatLabel(): string { + return this.label.trim(); + } +} diff --git a/test/resources/repos/angular/test_repo/src/index.html b/test/resources/repos/angular/test_repo/src/index.html new file mode 100644 index 00000000..e7cf87db --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/index.html @@ -0,0 +1,10 @@ + + + + + Serena Angular Test + + + + + diff --git a/test/resources/repos/angular/test_repo/src/main.ts b/test/resources/repos/angular/test_repo/src/main.ts new file mode 100644 index 00000000..31c5da48 --- /dev/null +++ b/test/resources/repos/angular/test_repo/src/main.ts @@ -0,0 +1,4 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent).catch((err) => console.error(err)); diff --git a/test/resources/repos/angular/test_repo/tsconfig.app.json b/test/resources/repos/angular/test_repo/tsconfig.app.json new file mode 100644 index 00000000..e228136a --- /dev/null +++ b/test/resources/repos/angular/test_repo/tsconfig.app.json @@ -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"] +} diff --git a/test/resources/repos/angular/test_repo/tsconfig.json b/test/resources/repos/angular/test_repo/tsconfig.json new file mode 100644 index 00000000..ca72349a --- /dev/null +++ b/test/resources/repos/angular/test_repo/tsconfig.json @@ -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 + } +} diff --git a/test/resources/repos/html/test_repo/about.html b/test/resources/repos/html/test_repo/about.html new file mode 100644 index 00000000..4adb182a --- /dev/null +++ b/test/resources/repos/html/test_repo/about.html @@ -0,0 +1,22 @@ + + + + + About — Serena HTML Test + + + + +
    +
    +

    About this repo

    +

    Used by the Serena language server tests.

    +
    +
    + + diff --git a/test/resources/repos/html/test_repo/diagnostics_sample.html b/test/resources/repos/html/test_repo/diagnostics_sample.html new file mode 100644 index 00000000..1b2164f7 --- /dev/null +++ b/test/resources/repos/html/test_repo/diagnostics_sample.html @@ -0,0 +1,14 @@ + + + + + + Diagnostics Sample + + +
    first
    +
    second
    + + diff --git a/test/resources/repos/html/test_repo/index.html b/test/resources/repos/html/test_repo/index.html new file mode 100644 index 00000000..4691b2d7 --- /dev/null +++ b/test/resources/repos/html/test_repo/index.html @@ -0,0 +1,35 @@ + + + + + Serena HTML Test + + + + +
    +
    +

    Introduction

    +

    A minimal HTML test fixture used by the Serena language-server tests.

    +
    +
    +

    Features

    +
      +
    • Symbol detection
    • +
    • Element navigation
    • +
    • Cross-file links
    • +
    +
    +
    +
    +

    © Serena

    +
    + + + diff --git a/test/resources/repos/scss/test_repo/_mixins.scss b/test/resources/repos/scss/test_repo/_mixins.scss new file mode 100644 index 00000000..88b9ff99 --- /dev/null +++ b/test/resources/repos/scss/test_repo/_mixins.scss @@ -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; +} diff --git a/test/resources/repos/scss/test_repo/_variables.scss b/test/resources/repos/scss/test_repo/_variables.scss new file mode 100644 index 00000000..9ba4b6e4 --- /dev/null +++ b/test/resources/repos/scss/test_repo/_variables.scss @@ -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; diff --git a/test/resources/repos/scss/test_repo/buttons.scss b/test/resources/repos/scss/test_repo/buttons.scss new file mode 100644 index 00000000..13a21dd1 --- /dev/null +++ b/test/resources/repos/scss/test_repo/buttons.scss @@ -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; +} diff --git a/test/resources/repos/scss/test_repo/css/diagnostics_sample.css b/test/resources/repos/scss/test_repo/css/diagnostics_sample.css new file mode 100644 index 00000000..a0a21dd9 --- /dev/null +++ b/test/resources/repos/scss/test_repo/css/diagnostics_sample.css @@ -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; +} diff --git a/test/resources/repos/scss/test_repo/css/main.css b/test/resources/repos/scss/test_repo/css/main.css new file mode 100644 index 00000000..d1b7fbea --- /dev/null +++ b/test/resources/repos/scss/test_repo/css/main.css @@ -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; + } +} diff --git a/test/resources/repos/scss/test_repo/css/reset.css b/test/resources/repos/scss/test_repo/css/reset.css new file mode 100644 index 00000000..f81c6a93 --- /dev/null +++ b/test/resources/repos/scss/test_repo/css/reset.css @@ -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; +} diff --git a/test/resources/repos/scss/test_repo/css/theme.css b/test/resources/repos/scss/test_repo/css/theme.css new file mode 100644 index 00000000..3e82d36d --- /dev/null +++ b/test/resources/repos/scss/test_repo/css/theme.css @@ -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; + } +} diff --git a/test/resources/repos/scss/test_repo/diagnostics_sample.scss b/test/resources/repos/scss/test_repo/diagnostics_sample.scss new file mode 100644 index 00000000..16609f1c --- /dev/null +++ b/test/resources/repos/scss/test_repo/diagnostics_sample.scss @@ -0,0 +1,5 @@ +// Some Sass flags references to undefined variables and missing semicolons. +.broken { + color: $undefined-variable + background: $also-not-defined; +} diff --git a/test/resources/repos/scss/test_repo/main.scss b/test/resources/repos/scss/test_repo/main.scss new file mode 100644 index 00000000..17836934 --- /dev/null +++ b/test/resources/repos/scss/test_repo/main.scss @@ -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; +} diff --git a/test/solidlsp/angular/conftest.py b/test/solidlsp/angular/conftest.py new file mode 100644 index 00000000..d2595084 --- /dev/null +++ b/test/solidlsp/angular/conftest.py @@ -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") diff --git a/test/solidlsp/angular/test_angular_basic.py b/test/solidlsp/angular/test_angular_basic.py new file mode 100644 index 00000000..9d2054b5 --- /dev/null +++ b/test/solidlsp/angular/test_angular_basic.py @@ -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 , , , , <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}" + ) diff --git a/test/solidlsp/angular/test_angular_diagnostics.py b/test/solidlsp/angular/test_angular_diagnostics.py new file mode 100644 index 00000000..d1f02bdb --- /dev/null +++ b/test/solidlsp/angular/test_angular_diagnostics.py @@ -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, + ) diff --git a/test/solidlsp/angular/test_angular_error_cases.py b/test/solidlsp/angular/test_angular_error_cases.py new file mode 100644 index 00000000..52b51592 --- /dev/null +++ b/test/solidlsp/angular/test_angular_error_cases.py @@ -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 diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py index ee563757..de894f13 100644 --- a/test/solidlsp/conftest.py +++ b/test/solidlsp/conftest.py @@ -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() diff --git a/test/solidlsp/html_ls/__init__.py b/test/solidlsp/html_ls/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/html_ls/test_html_basic.py b/test/solidlsp/html_ls/test_html_basic.py new file mode 100644 index 00000000..1a3444ef --- /dev/null +++ b/test/solidlsp/html_ls/test_html_basic.py @@ -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 diff --git a/test/solidlsp/html_ls/test_html_diagnostics.py b/test/solidlsp/html_ls/test_html_diagnostics.py new file mode 100644 index 00000000..7fb7df26 --- /dev/null +++ b/test/solidlsp/html_ls/test_html_diagnostics.py @@ -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 diff --git a/test/solidlsp/scss/__init__.py b/test/solidlsp/scss/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/scss/test_scss_basic.py b/test/solidlsp/scss/test_scss_basic.py new file mode 100644 index 00000000..ed879585 --- /dev/null +++ b/test/solidlsp/scss/test_scss_basic.py @@ -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}" + ) diff --git a/test/solidlsp/scss/test_scss_diagnostics.py b/test/solidlsp/scss/test_scss_diagnostics.py new file mode 100644 index 00000000..f5a2c903 --- /dev/null +++ b/test/solidlsp/scss/test_scss_diagnostics.py @@ -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, + )