Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/01-about/020_programming-languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
16 changes: 16 additions & 0 deletions src/serena/util/inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
221 changes: 221 additions & 0 deletions src/solidlsp/language_servers/deno_language_server.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 22 additions & 0 deletions src/solidlsp/ls_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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}")

Expand Down
1 change: 1 addition & 0 deletions test/resources/repos/deno/test_repo/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
17 changes: 17 additions & 0 deletions test/resources/repos/deno/test_repo/mod.ts
Original file line number Diff line number Diff line change
@@ -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();
}
9 changes: 9 additions & 0 deletions test/resources/repos/deno/test_repo/use_mod.ts
Original file line number Diff line number Diff line change
@@ -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();
3 changes: 3 additions & 0 deletions test/serena/test_serena_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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"],
)
Expand Down Expand Up @@ -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"],
)
Expand Down
Loading
Loading