diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e9b73699..a502c589 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -71,7 +71,7 @@ jobs: # other-langs: grab-bag + scripting languages + haskell + qml. Haskell (GHC/HLS) and qml (qmlls) are # installed only on Linux -- too slow/expensive to build on the macOS runner (Haskell), or not worth # the cost of a cross-OS Qt pull (qmlls) -- and skip on Windows+macOS via the central conftest guard. - MARKERS_OTHER_LANGS: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or haxe or haskell or terraform or rego or ansible or yaml or toml or markdown or latex or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al or qml" + MARKERS_OTHER_LANGS: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or deno or haxe or haskell or terraform or rego or ansible or yaml or toml or markdown or latex or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al or qml" # niche: slow, mostly-cached toolchains. ocaml + haskell are the two slowest -- kept in separate # batches (ocaml here, haskell in other-langs). nix + perl skip on Windows via the guard. MARKERS_NICHE: "julia or r or perl or lean4 or nix or ocaml" @@ -219,6 +219,13 @@ jobs: with: terraform_version: "1.5.0" terraform_wrapper: false + # Deno ships its language server with the CLI (`deno lsp`), so installing the + # runtime is all that is needed for the deno-marked tests. + - name: Install Deno + if: matrix.batch == 'other-langs' + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x # - name: Install swift # if: runner.os != 'Windows' # uses: swift-actions/setup-swift@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 256dd973..6496aa69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,11 @@ Status of the `main` branch. Changes prior to the next official version change w or `textDocument/didChange`), as this is always the intention of calling the method. If files were kept open in the language server (which the Svelte and Vue language servers did), the language server was not necessarily informed about updated contents. + - Add Deno support (experimental; language server `deno`, backed by the Deno CLI's built-in + `deno lsp`). Understands Deno module resolution (`npm:` / `jsr:` / `https:` imports) and the + `Deno.*` globals, which the plain TypeScript language server does not. Overlaps TypeScript on + file extensions, so it is not auto-detected and must be selected explicitly; requires the + `deno` CLI on PATH * JetBrains: - `jet_brains_find_symbol`: Disallow wildcard-only search, delegating to overview tool if request is for file diff --git a/README.md b/README.md index 2ff37674..70e9d4c7 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 -Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, WGSL, YAML, and Zig. +Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Deno, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Pascal, Perl, PHP, PowerShell, Python, QML, R, Rego, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, SystemVerilog, Terraform, TOML, TypeScript, Vue, 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 00bfe33b..25ac8146 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -164,6 +164,12 @@ Some languages require additional installations or setup steps, as noted. * **TOML** (experimental; uses Taplo 0.10.0, taken from PATH if present, otherwise downloaded automatically) * **TypeScript** +* **Deno** + (experimental; requires the `deno` CLI on PATH — it bundles the language server used here; + serves Deno TypeScript/JavaScript and understands `npm:` / `jsr:` / `https:` imports and the `Deno.*` + globals, which the plain TypeScript language server does not; overlaps `typescript` on file extensions, + so it is not auto-detected and must be set as the language explicitly — do not also enable `typescript` + for the same files) * **Vue** (3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection) * **YAML** diff --git a/pyproject.toml b/pyproject.toml index e81b2b5d..6731feeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -328,6 +328,7 @@ markers = [ "groovy: language server running for Groovy", "rust: language server running for Rust", "typescript: language server running for TypeScript", + "deno: language server running for Deno (uses the built-in deno lsp)", "svelte: language server running for Svelte (uses svelte-language-server)", "vue: language server running for Vue (uses TypeScript LSP)", "php: language server running for PHP", diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index cb5967ff..3d22e3d6 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -4,18 +4,18 @@ project_name: "project_name" # list of language servers to start when using the LSP backend; choose from: # ada al angular ansible bash # bsl clojure cpp cpp_ccls crystal -# csharp csharp_omnisharp cue dart elixir -# elm erlang fortran fsharp gdscript -# go groovy haskell haxe hlsl -# html java json julia kotlin -# latex lean4 lua luau markdown -# matlab msl nix ocaml pascal -# perl php php_phpactor php_phpantom powershell -# python python_basedpyright python_jedi python_pyrefly python_ty -# qml r rego ruby ruby_solargraph -# rust scala scss solidity svelte -# swift systemverilog terraform toml typescript -# typescript_vts vue yaml zig +# csharp csharp_omnisharp cue dart deno +# elixir elm erlang fortran fsharp +# gdscript go groovy haskell haxe +# hlsl html java json julia +# kotlin latex lean4 lua luau +# markdown matlab msl nix ocaml +# pascal perl php php_phpactor php_phpantom +# powershell python python_basedpyright python_jedi python_pyrefly +# python_ty qml r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig # (This list may be outdated; generated with scripts/print_language_list.py; # For the current list, see values of the LanguageServerId enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) @@ -25,6 +25,7 @@ project_name: "project_name" # - For JavaScript, use typescript # - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) # - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH) # - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: diff --git a/src/solidlsp/language_servers/deno_language_server.py b/src/solidlsp/language_servers/deno_language_server.py new file mode 100644 index 00000000..c9649ced --- /dev/null +++ b/src/solidlsp/language_servers/deno_language_server.py @@ -0,0 +1,152 @@ +""" +Provides Deno-specific instantiation of the LanguageServer class, using the +language server built into the Deno CLI (``deno lsp``). +""" + +import logging +import shutil + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class DenoLanguageServer(SolidLanguageServer): + """ + Deno instantiation of the LanguageServer class, backed by ``deno lsp``. + + Serves TypeScript/JavaScript in Deno projects. Unlike the plain + typescript-language-server, ``deno lsp`` understands Deno-specific module + resolution (``npm:`` / ``jsr:`` / ``https:`` imports) and the ``Deno.*`` + global namespace. + + This server overlaps the TypeScript server on file extensions and is therefore + marked experimental: it is not auto-detected and must be selected explicitly via + ``languages: [deno]`` in ``project.yml``. + """ + + @classmethod + def supports_implementation_request(cls) -> bool: + return True + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__( + config, + repository_root_path, + None, + "typescript", + solidlsp_settings, + ) + + 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: + # node_modules appears in Deno projects using npm compatibility; vendor/ holds + # vendored remote dependencies. Neither should be indexed as project sources. + return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "dist", "build"] + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """Return the path to the ``deno`` executable (the language server ships with it).""" + deno_path = shutil.which("deno") + if deno_path is None: + raise FileNotFoundError( + "The 'deno' executable was not found on PATH. Install Deno " + "(https://docs.deno.com/runtime/getting_started/installation/) — it bundles " + "the language server used here — and ensure 'deno' is on your PATH." + ) + return deno_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "lsp"] + + def _get_language_id_for_file(self, relative_file_path: str) -> str: + # deno lsp relies on the correct languageId; .tsx/.jsx in particular must not + # be sent as plain "typescript" or symbol ranges get truncated at JSX expressions. + if relative_file_path.endswith(".tsx"): + return "typescriptreact" + if relative_file_path.endswith(".jsx"): + return "javascriptreact" + if relative_file_path.endswith((".js", ".mjs", ".cjs")): + return "javascript" + return "typescript" + + def _create_base_initialize_params(self) -> dict: + return { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + "workspace": { + "workspaceFolders": True, + "configuration": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + # deno lsp reads its settings from initializationOptions; enabling the server + # and the linter mirrors the defaults of the official VS Code Deno extension. + "initializationOptions": { + "enable": True, + "lint": True, + "unstable": False, + }, + } + + def _start_server(self) -> None: + """Start the ``deno lsp`` process and drive the LSP initialize handshake.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + return + + def configuration_handler(params: dict) -> list: + # deno lsp requests workspace/configuration during startup; return an empty + # settings object per requested item so it proceeds with its defaults. + return [{} for _ in params.get("items", [])] + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", configuration_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + # Deno-specific notifications emitted after config discovery; no action needed. + self.server.on_notification("deno/didRefreshDenoConfigurationTree", do_nothing) + self.server.on_notification("deno/didChangeDenoConfiguration", do_nothing) + + log.info("Starting deno lsp server process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to deno lsp and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + assert "textDocumentSync" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "referencesProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 2db41106..25a9f720 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -286,6 +286,14 @@ class LanguageServerId(str, Enum): project.yml — Angular LS supersedes both for Angular projects. Must be explicitly specified in project.yml. """ + DENO = "deno" + """Deno's built-in language server (``deno lsp``) for Deno TypeScript/JavaScript projects. + Understands Deno module resolution (``npm:`` / ``jsr:`` / ``https:`` imports) and the + ``Deno.*`` globals, which the plain typescript-language-server does not. Overlaps the + TypeScript server on file extensions (.ts/.tsx/.js/.jsx/.mts/.cts/.mjs/.cjs), so it is + experimental and must be explicitly specified via ``languages: [deno]`` in project.yml; + do not also enable typescript for the same files. Requires the ``deno`` CLI on PATH. + """ @classmethod def iter_all(cls, include_experimental: bool = True, include_non_programming_languages: bool = True) -> Iterable[Self]: @@ -321,6 +329,7 @@ class LanguageServerId(str, Enum): self.HTML, self.SCSS, self.ANGULAR, + self.DENO, } def is_programming_language(self) -> bool: @@ -588,6 +597,14 @@ class LanguageServerId(str, Enum): for postfix in ["x", ""]: path_patterns.append(f".{prefix}ts{postfix}") return FilenameMatcher(*path_patterns) + case self.DENO: + # Deno serves the same TS/JS family as the TypeScript server. + path_patterns = [] + for prefix in ["c", "m", ""]: + for postfix in ["x", ""]: + for base_pattern in ["ts", "js"]: + path_patterns.append(f".{prefix}{base_pattern}{postfix}") + return FilenameMatcher(*path_patterns) case _: raise ValueError(f"Unhandled language: {self}") @@ -871,6 +888,10 @@ class LanguageServerId(str, Enum): from solidlsp.language_servers.angular_language_server import AngularLanguageServer return AngularLanguageServer + case self.DENO: + from solidlsp.language_servers.deno_language_server import DenoLanguageServer + + return DenoLanguageServer case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/conftest.py b/test/conftest.py index d973bb48..796523e4 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -286,6 +286,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[LanguageServerId, list[MarkDecorator | Mark]] = { LanguageServerId.CPP_CCLS: [pytest.mark.cpp], LanguageServerId.CUE: [pytest.mark.cue], LanguageServerId.CSHARP: [pytest.mark.csharp], + LanguageServerId.DENO: [pytest.mark.deno], LanguageServerId.FSHARP: [pytest.mark.fsharp], LanguageServerId.GO: [pytest.mark.go], LanguageServerId.HAXE: [pytest.mark.haxe], @@ -477,6 +478,8 @@ def _determine_disabled_language_servers() -> list[LanguageServerId]: result.append(LanguageServerId.OCAML) if not _is_perl_language_server_available(): # perl ships with the OS; the LS module is the real signal result.append(LanguageServerId.PERL) + if _sh.which("deno") is None: # deno bundles the language server (`deno lsp`); skip where the CLI is absent + result.append(LanguageServerId.DENO) # === 4. Enabled everywhere: every language NOT listed in this function (python, go, java, ...) === diff --git a/test/resources/repos/deno/test_repo/deno.json b/test/resources/repos/deno/test_repo/deno.json new file mode 100644 index 00000000..2f35542b --- /dev/null +++ b/test/resources/repos/deno/test_repo/deno.json @@ -0,0 +1,7 @@ +{ + "lint": { + "rules": { + "tags": ["recommended"] + } + } +} diff --git a/test/resources/repos/deno/test_repo/main.ts b/test/resources/repos/deno/test_repo/main.ts new file mode 100644 index 00000000..34493ed6 --- /dev/null +++ b/test/resources/repos/deno/test_repo/main.ts @@ -0,0 +1,15 @@ +import { add } from "./util.ts"; + +export class Calculator { + sum(a: number, b: number): number { + return add(a, b); + } +} + +// Uses the Deno global namespace, which the plain TypeScript language server does not know about. +export function describeCwd(): string { + return `cwd: ${Deno.cwd()}`; +} + +const calc = new Calculator(); +console.log(calc.sum(2, 3)); diff --git a/test/resources/repos/deno/test_repo/util.ts b/test/resources/repos/deno/test_repo/util.ts new file mode 100644 index 00000000..8559ea54 --- /dev/null +++ b/test/resources/repos/deno/test_repo/util.ts @@ -0,0 +1,7 @@ +export function add(a: number, b: number): number { + return a + b; +} + +export function subtract(a: number, b: number): number { + return a - b; +} diff --git a/test/solidlsp/deno/test_deno_basic.py b/test/solidlsp/deno/test_deno_basic.py new file mode 100644 index 00000000..3efcb0f8 --- /dev/null +++ b/test/solidlsp/deno/test_deno_basic.py @@ -0,0 +1,89 @@ +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import LanguageServerId +from solidlsp.ls_types import Hover +from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_server_tests_enabled + + +def _hover_text(hover: Hover | None) -> str: + """Flatten the hover contents (markup object, plain string or list of either) into one string.""" + assert hover is not None, "Expected hover information, got None" + contents = hover["contents"] + items = contents if isinstance(contents, list) else [contents] + return "\n".join(item if isinstance(item, str) else item["value"] for item in items) + + +@pytest.mark.skipif(not language_server_tests_enabled(LanguageServerId.DENO), reason="Deno tests are disabled (deno not available)") +@pytest.mark.deno +class TestDenoLanguageServer: + @pytest.mark.parametrize("language_server", [LanguageServerId.DENO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.DENO], 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", [LanguageServerId.DENO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.DENO], indirect=True) + def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # main.ts line 4 (0-indexed): " return add(a, b);" — cursor on `add` (char 11). + # `add` is defined in util.ts at line 0. + definitions = language_server.request_definition(str(repo_path / "main.ts"), 4, 11) + + assert definitions, f"Expected a definition for `add`, got {definitions=}" + definition = definitions[0] + assert definition["uri"].endswith("util.ts") + assert definition["range"]["start"]["line"] == 0 + + @pytest.mark.parametrize("language_server", [LanguageServerId.DENO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.DENO], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # `add` is defined in util.ts line 0 at char len("export function ") == 16. + references = language_server.request_references(str(repo_path / "util.ts"), 0, len("export function ")) + + assert references, f"Expected references for `add`, got {references=}" + locations = [(ref["uri"].split("/")[-1], ref["range"]["start"]["line"]) for ref in references] + # The usage inside Calculator.sum lives on line 4 of main.ts. + assert ("main.ts", 4) in locations, f"Expected a reference in main.ts line 4, got {locations}" + + @pytest.mark.parametrize("language_server", [LanguageServerId.DENO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.DENO], indirect=True) + def test_document_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + all_symbols, _roots = language_server.request_document_symbols("main.ts").get_all_symbols_and_roots() + names = [sym.get("name") for sym in all_symbols] + assert "Calculator" in names, f"Calculator not found in main.ts document symbols: {names}" + assert "sum" in names, f"sum not found in main.ts document symbols: {names}" + + @pytest.mark.parametrize("language_server", [LanguageServerId.DENO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.DENO], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add not found in symbol tree" + + @pytest.mark.parametrize("language_server", [LanguageServerId.DENO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.DENO], indirect=True) + def test_hover_reports_function_signature(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + # `add` is defined in util.ts line 0 at char len("export function ") == 16. + hover = language_server.request_hover(str(repo_path / "util.ts"), 0, len("export function ")) + + text = _hover_text(hover) + assert "add" in text, f"Hover should name the function, got: {text}" + assert "(a: number, b: number): number" in text, f"Hover should carry the signature, got: {text}" + + @pytest.mark.parametrize("language_server", [LanguageServerId.DENO], indirect=True) + @pytest.mark.parametrize("repo_path", [LanguageServerId.DENO], indirect=True) + def test_hover_resolves_deno_global(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """The `Deno.*` namespace is what this server adds over the plain TypeScript one, so it must resolve.""" + main = repo_path / "main.ts" + lines = main.read_text(encoding="utf-8").splitlines() + line = next(i for i, content in enumerate(lines) if "Deno.cwd()" in content) + + hover = language_server.request_hover(str(main), line, lines[line].index("Deno.cwd") + len("Deno.")) + + text = _hover_text(hover) + assert "cwd" in text, f"Hover should resolve Deno.cwd, got: {text}" + assert "string" in text, f"Hover should carry Deno.cwd's return type, got: {text}"