diff --git a/CHANGELOG.md b/CHANGELOG.md index 4947567925..36b1f57715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Language support: + * **Add support for Deno** via the built-in `deno lsp` (auto-installed via npm if not found on PATH; auto-detected when `deno.json`, `deno.jsonc`, or `deno.lock` is found in the project root) * **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+) * **Add support for Fortran** via fortls language server (requires `pip install fortls`) * **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md)) diff --git a/README.md b/README.md index b03b07c124..a5418719f5 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ that implement the language server protocol (LSP). The underlying language servers are typically open-source projects (like Serena) or at least freely available for use. With Serena's LSP library, we provide **support for over 30 programming languages**, including -AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig. +AL, Bash, C#, C/C++, Clojure, Dart, Deno, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig. > [!IMPORTANT] > Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 870403088f..4c39dd2196 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -38,6 +38,8 @@ Some languages require additional installations or setup steps, as noted. see the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details.) * **Clojure** * **Dart** +* **Deno** + (auto-installed via npm if not found on PATH; uses the built-in `deno lsp`; auto-detected when `deno.json`, `deno.jsonc`, or `deno.lock` is found in the project root) * **Elixir** (requires Elixir installation; Expert language server is downloaded automatically) * **Elm** diff --git a/pyproject.toml b/pyproject.toml index 044a287255..0d91b64cdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -322,6 +322,7 @@ markers = [ "slow: tests that require additional Expert instances and have long startup times (~60-90s each)", "toml: language server running for TOML", "matlab: language server running for MATLAB (requires MATLAB R2021b+)", + "deno: language server running for Deno", ] [tool.codespell] diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py index c67cc1f356..fdb9bbc77b 100644 --- a/src/serena/util/inspection.py +++ b/src/serena/util/inspection.py @@ -49,6 +49,22 @@ def determine_programming_language_composition(repo_path: str) -> dict[Language, if count > 0: language_counts[language] = count + # Resolve conflicts between languages that share file extensions. + # Languages can declare project markers that give them precedence over another language. + for language in list(language_counts): + override = language.get_marker_override() + if override is None: + continue + markers, overrides = override + if overrides not in language_counts: + continue + if any(os.path.exists(os.path.join(repo_path, m)) for m in markers): + log.info("Project marker found for %s, using instead of %s", language, overrides) + del language_counts[overrides] + else: + log.info("No project marker for %s, keeping %s", language, overrides) + del language_counts[language] + # Convert counts to percentages language_percentages: dict[Language, float] = {} for language, count in language_counts.items(): 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 0000000000..dbba3631cf --- /dev/null +++ b/src/solidlsp/language_servers/deno_language_server.py @@ -0,0 +1,221 @@ +""" +Provides Deno specific instantiation of the LanguageServer class using the built-in `deno lsp`. +Contains various configurations and settings specific to Deno. +""" + +import logging +import os +import pathlib +import platform +import shutil +import threading + +from overrides import override +from sensai.util.logging import LogTime + +from solidlsp import ls_types +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 + +from .common import RuntimeDependency, RuntimeDependencyCollection +from .typescript_language_server import prefer_non_node_modules_definition + +log = logging.getLogger(__name__) + + +class DenoLanguageServer(SolidLanguageServer): + """ + Provides Deno specific instantiation of the LanguageServer class using the built-in `deno lsp`. + + If Deno is not found on the system PATH, it will be automatically installed via the official npm package. + + Auto-detected when deno.json, deno.jsonc, or deno.lock is found in the project root. + + You can pass the following entries in ls_specific_settings["deno"]: + - ls_path: Path to the Deno executable (default: auto-detected from PATH, falls back to npm install) + - deno_version: Pin a specific Deno version for npm install (default: latest) + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__( + config, + repository_root_path, + None, + "typescript", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + 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: + """Find or install the Deno executable. + + First checks if Deno is available on PATH. If not, falls back to + installing via npm (using the official 'deno' npm package). + """ + deno_path = shutil.which("deno") + if deno_path is not None: + return deno_path + + # Fall back to npm-based installation + is_node_installed = shutil.which("node") is not None + is_npm_installed = shutil.which("npm") is not None + assert is_node_installed and is_npm_installed, ( + "Deno is not installed and Node.js/npm are not available for auto-install. " + "Please install Deno (https://deno.com) or Node.js and try again." + ) + + deno_version = self._custom_settings.get("deno_version") + install_spec = f"deno@{deno_version}" if deno_version else "deno" + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="deno", + description="Deno runtime (official npm distribution)", + command=["npm", "install", "--prefix", "./", install_spec], + platform_id="any", + ), + ] + ) + + deno_ls_dir = os.path.join(self._ls_resources_dir, "deno-lsp") + binary_name = "deno.exe" if platform.system() == "Windows" else "deno" + deno_executable = os.path.join(deno_ls_dir, "node_modules", "deno", binary_name) + + # Check if installation is needed + version_file = os.path.join(deno_ls_dir, ".installed_version") + expected_version = deno_version or "latest" + + needs_install = False + if not os.path.exists(deno_executable): + log.info(f"Deno executable not found at {deno_executable}.") + needs_install = True + elif os.path.exists(version_file): + with open(version_file) as f: + installed_version = f.read().strip() + if installed_version != expected_version: + log.info(f"Deno version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling...") + needs_install = True + else: + log.info(f"Deno version file not found at {version_file}. Reinstalling to ensure correct setup...") + needs_install = True + + if needs_install: + log.info("Installing Deno via npm...") + with LogTime("Installation of Deno runtime", logger=log): + deps.install(deno_ls_dir) + with open(version_file, "w") as f: + f.write(expected_version) + log.info("Deno installed successfully via npm") + + if not os.path.exists(deno_executable): + raise FileNotFoundError(f"Deno executable not found at {deno_executable}, something went wrong with the installation.") + return deno_executable + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "lsp"] + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True}, + "documentSymbol": { + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"contentFormat": ["markdown", "plaintext"]}, + "rename": {"prepareSupport": True}, + }, + "workspace": { + "workspaceFolders": True, + "configuration": True, + }, + }, + "initializationOptions": { + "enable": True, + }, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """Starts the Deno Language Server, waits for the server to be ready.""" + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + return + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def workspace_configuration_handler(params: dict) -> list: # type: ignore[type-arg] + """Handle workspace/configuration requests from Deno LSP.""" + result: list[dict | None] = [] + for item in params.get("items", []): + if item.get("section") == "deno": + result.append({"enable": True}) + elif item.get("section") in ("typescript", "javascript"): + result.append( + { + "preferences": { + "importModuleSpecifierPreference": "relative", + }, + } + ) + else: + result.append(None) + return result + + self.server.on_request("client/registerCapability", register_capability_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) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + + log.info("Starting Deno server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to Deno LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Deno server: {init_response}") + + assert "capabilities" in init_response + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + + # Deno LSP is typically ready quickly after initialized notification + log.info("Deno server initialization complete") + self.server_ready.set() + + @override + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + return prefer_non_node_modules_definition(definitions) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 812e16c429..154cb47c2b 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -105,6 +105,10 @@ class Language(str, Enum): """TOML language server using Taplo. Supports TOML validation, formatting, and schema support. """ + DENO = "deno" + """Deno language server using the built-in `deno lsp`. + Auto-detected when deno.json, deno.jsonc, or deno.lock is found in the project root. + """ @classmethod def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]: @@ -153,6 +157,18 @@ def get_priority(self) -> int: case _: return 2 + def get_marker_override(self) -> tuple[tuple[str, ...], "Language"] | None: + """ + If this language shares file extensions with another, return + (marker_files, language_to_override). When any marker file exists + in the project root, this language takes precedence over the other. + """ + match self: + case self.DENO: + return ("deno.json", "deno.jsonc", "deno.lock"), Language.TYPESCRIPT + case _: + return None + def get_source_fn_matcher(self) -> FilenameMatcher: match self: case self.PYTHON | self.PYTHON_JEDI: @@ -246,6 +262,8 @@ def get_source_fn_matcher(self) -> FilenameMatcher: return FilenameMatcher("*.groovy", "*.gvy") case self.MATLAB: return FilenameMatcher("*.m", "*.mlx", "*.mlapp") + case self.DENO: + return FilenameMatcher("*.ts", "*.tsx", "*.js", "*.jsx") case _: raise ValueError(f"Unhandled language: {self}") @@ -427,6 +445,10 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: from solidlsp.language_servers.matlab_language_server import MatlabLanguageServer return MatlabLanguageServer + 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/resources/repos/deno/test_repo/deno.json b/test/resources/repos/deno/test_repo/deno.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/test/resources/repos/deno/test_repo/deno.json @@ -0,0 +1 @@ +{} diff --git a/test/resources/repos/deno/test_repo/mod.ts b/test/resources/repos/deno/test_repo/mod.ts new file mode 100644 index 0000000000..cf52dd311e --- /dev/null +++ b/test/resources/repos/deno/test_repo/mod.ts @@ -0,0 +1,17 @@ +import { join } from "jsr:@std/path@1"; + +export class DemoClass { + value: number; + constructor(value: number) { + this.value = value; + } + printValue(): void { + console.log(join("value", String(this.value))); + } +} + +export function helperFunction(): string { + const demo = new DemoClass(42); + demo.printValue(); + return Deno.cwd(); +} diff --git a/test/resources/repos/deno/test_repo/use_mod.ts b/test/resources/repos/deno/test_repo/use_mod.ts new file mode 100644 index 0000000000..287b605ad0 --- /dev/null +++ b/test/resources/repos/deno/test_repo/use_mod.ts @@ -0,0 +1,9 @@ +import { helperFunction } from "./mod.ts"; +import { assertEquals } from "https://deno.land/std@0.224.0/assert/assert_equals.ts"; + +export function useHelper(): void { + const cwd = helperFunction(); + assertEquals(typeof cwd, "string"); +} + +useHelper(); diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 3a3a8144c0..a0bafee079 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -37,6 +37,7 @@ def serena_config(): Language.FSHARP, Language.POWERSHELL, Language.CPP_CCLS, + Language.DENO, ]: repo_path = get_repo_path(language) if repo_path.exists(): @@ -120,6 +121,7 @@ class TestSerenaAgent: pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell), pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), + pytest.param(Language.DENO, "DemoClass", "Class", "mod.ts", marks=pytest.mark.deno), ], indirect=["serena_agent"], ) @@ -195,6 +197,7 @@ def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell), pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), + pytest.param(Language.DENO, "helperFunction", "mod.ts", "use_mod.ts", marks=pytest.mark.deno), ], indirect=["serena_agent"], ) diff --git a/test/solidlsp/deno/test_deno_basic.py b/test/solidlsp/deno/test_deno_basic.py new file mode 100644 index 0000000000..3ff585aed9 --- /dev/null +++ b/test/solidlsp/deno/test_deno_basic.py @@ -0,0 +1,32 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +@pytest.mark.deno +class TestDenoLanguageServer: + @pytest.mark.parametrize("language_server", [Language.DENO], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoClass"), "DemoClass not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "helperFunction"), "helperFunction not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "printValue"), "printValue method not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.DENO], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("mod.ts") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + helper_symbol = None + for sym in symbols[0]: + if sym.get("name") == "helperFunction": + helper_symbol = sym + break + assert helper_symbol is not None, "Could not find 'helperFunction' symbol in mod.ts" + sel_start = helper_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert any("mod.ts" in ref.get("relativePath", "") for ref in refs), "mod.ts should reference helperFunction" + assert any("use_mod.ts" in ref.get("relativePath", "") for ref in refs), "use_mod.ts should reference helperFunction via import"