diff --git a/CHANGELOG.md b/CHANGELOG.md index e56d4f52..108368db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,36 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* Language Servers: + - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. + The dict is forwarded to vtsls via `initializationOptions`, `workspace/didChangeConfiguration`, + and `workspace/configuration` pulls. Enables Yarn PnP setups with `typescript.tsdk` pointing + at the Yarn-generated SDK. + * Dashboard: + - Make list of trusted hosts configurable, fixing host validation introduced in v1.5.2 allowing only + default local hostnames, effectively preventing remote connections - Decouple configuration computation from the agent's task queue by introducing events for agent config/status updates. This allows the dashboard to display the configuration while the project provided at startup is still initialising. #1064 - Fix empty executions queue displaying "Loading..." - + +# v1.5.3 (2026-05-26) + +Add meta-data for the GitHub MCP registry + +# v1.5.2 (2026-05-26) + +* General: + - Not existing paths return `False` on is ignored checks (instead of raising an error) + - Add `serena-agent` CLI command so that `uvx serena-agent` can be used as entrypoint. + - Fortls and pyright are now installed on the fly instead of being bundled in the serena-agent package. + +* Dashboard: + - Add host validation + +* Hooks: + - Extend list of extensions that are considered code files (affects the reminder hook counter). + # v1.5.1 (2026-05-18) * General: diff --git a/README.md b/README.md index 30d7c297..44cdee0f 100644 --- a/README.md +++ b/README.md @@ -226,7 +226,7 @@ To achieve this, Serena offers multiple levels of (composable) configuration: **Install Serena**. Serena is installed via uv as follows: ```bash -uv tool install -p 3.13 serena-agent@latest --prerelease=allow +uv tool install -p 3.13 serena-agent ``` After successful installation, the command `serena` should be available in your shell. @@ -261,3 +261,5 @@ Please refer to the [user guide](https://oraios.github.io/serena/02-usage/000_in A significant part of Serena, especially support for various languages, was contributed by the open source community. We are very grateful for the many contributors who made this possible and who played an important role in making Serena what it is today. + + diff --git a/docs/02-usage/010_installation.md b/docs/02-usage/010_installation.md index 5b0f216b..dff3c1c6 100644 --- a/docs/02-usage/010_installation.md +++ b/docs/02-usage/010_installation.md @@ -20,7 +20,7 @@ to be provided manually, this is mentioned in the notes below the respective lan With `uv` installed and on your PATH, install Serena with this command: - uv tool install -p 3.13 serena-agent@latest --prerelease=allow + uv tool install -p 3.13 serena-agent Upon completion, the command `serena` should be available in your terminal. @@ -37,7 +37,7 @@ Note that you can switch backends at any time via Serena's [configuration](050_c To update Serena to the latest version, run: - uv tool upgrade serena-agent --prerelease=allow + uv tool upgrade serena-agent :::{tip} To keep informed about updates, make sure you regularly open [Serena's Dashboard](060_dashboard), diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 03731604..983924bb 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1022,6 +1022,30 @@ Supported settings: |---|---|---| | `vtsls_version` | `0.2.9` | Override the `@vtsls/language-server` npm package version Serena installs. | | `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | +| `initialization_options` | `null` | Dict forwarded to vtsls on three LSP channels: the `initializationOptions` field of the `initialize` request, a `workspace/didChangeConfiguration` notification sent right after initialize, and as the response to `workspace/configuration` pull requests (section-scoped). Typical use is Yarn PnP: point `typescript.tsdk` at the Yarn-generated SDK and enable `vtsls.autoUseWorkspaceTsdk`. | + +Example (Yarn PnP project with TypeScript in a subdirectory; run `yarn dlx @yarnpkg/sdks vscode` in the project once to generate the SDK): + +```yaml +ls_specific_settings: + typescript_vts: + initialization_options: + typescript: + tsdk: "project/.yarn/sdks/typescript/lib" + vtsls: + autoUseWorkspaceTsdk: true +``` + +vtsls reads `typescript.tsdk` through the `workspace/configuration` pull, not through `initializationOptions`, so Serena answers those pulls from the same dict (and also pushes it on `workspace/didChangeConfiguration` for compatibility with servers that expect the notification). Without `autoUseWorkspaceTsdk: true`, vtsls falls back to its bundled TypeScript and ignores `tsdk` (there is no UI prompt to confirm the switch in a headless LSP). + +The dict is forwarded to vtsls verbatim — Serena does not validate its structure. For the list of supported keys and their expected types, refer to the vtsls [configuration schema](https://github.com/yioneko/vtsls/blob/main/packages/service/configuration.schema.json) and the underlying [VS Code TypeScript settings](https://code.visualstudio.com/docs/languages/typescript). `null` (the default) and `{}` are both treated as "unset": no `initializationOptions` are sent and no `workspace/didChangeConfiguration` notification is pushed. A non-dict value (e.g. a string or list) raises an error at server start. + +**Troubleshooting:** + +- *vtsls keeps using its bundled TypeScript and ignores `tsdk`* — ensure `vtsls.autoUseWorkspaceTsdk: true` is set alongside `typescript.tsdk`. Without it vtsls does not auto-switch to the workspace TS in a headless LSP. +- *tsserver fails to start after pointing at a custom `tsdk`* — verify the path resolves to a directory containing `tsserver.js` (e.g. `.yarn/sdks/typescript/lib`, not `.yarn/sdks/typescript`). Relative paths are interpreted relative to the project root. +- *Setting appears in `solidlsp` logs but vtsls does not react* — cross-check the key against the vtsls configuration schema linked above. The dict is forwarded as-is, so an unknown or wrong-typed key is silently ignored by vtsls. +- *Need to inspect what Serena is actually forwarding* — the dict is logged at INFO level via the `Forwarding user-provided initializationOptions to vtsls: …` line at language server startup. #### Vue diff --git a/pyproject.toml b/pyproject.toml index a9b53049..9a03e553 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "serena-agent" -version = "1.5.2.dev0" +version = "1.5.4.dev0" description = "A powerful MCP toolkit for coding, providing semantic retrieval and editing capabilities - the IDE for your agent" authors = [{ name = "Oraios AI", email = "info@oraios-ai.de" }] readme = "README.md" @@ -18,8 +18,6 @@ classifiers = [ ] dependencies = [ "requests==2.33.0", - "pyright==1.1.403", - "fortls==3.2.2", "overrides==7.7.0", "python-dotenv==1.2.2", "mcp==1.27.0", @@ -52,7 +50,7 @@ dependencies = [ "filelock==3.25.2", "cryptography==46.0.7", "regex==2026.2.28", - "pythonnet==3.1.0-rc0 ; sys_platform == 'win32'", + "pythonnet==3.1.0 ; sys_platform == 'win32'", ] @@ -64,6 +62,7 @@ explicit = true [project.scripts] serena = "serena.cli:top_level" +serena-agent = "serena.cli:top_level" serena-hooks = "serena.hooks:hook_commands" [project.license] diff --git a/server.json b/server.json new file mode 100644 index 00000000..18fe98d0 --- /dev/null +++ b/server.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.oraios/serena", + "title": "Serena MCP: the IDE for your agent", + "description": "A powerful toolkit for coding, providing semantic retrieval and editing capabilities.", + "version": "1.5.3", + "repository": { + "source": "github", + "url": "https://github.com/oraios/serena" + }, + "packages": [ + { + "registryType": "pypi", + "identifier": "serena-agent", + "version": "1.5.3", + "transport": { + "type": "stdio" + }, + "runtimeHint": "uvx", + "runtimeArguments": [ + { + "description": "Select the python version", + "name": "-p", + "value": "3.13", + "type": "named" + }, + { + "type": "positional", + "value": "start-mcp-server" + }, + { + "type": "positional", + "value": "--project-from-cwd", + "isRequired": false + }, + { + "type": "named", + "name": "--context", + "isRequired": false, + "default": "desktop-app", + "description": "The name of a markdown file representing Serena's execution context, typically related to the executing client. Allowed values are: agent, antigravity, chatgpt, claude-code, codex, context.template, copilot-cli, desktop-app, ide, jb-ai-assistant, jb-copilot-plugin, junie, oaicompat-agent, vscode " + } + ] + } + ] +} \ No newline at end of file diff --git a/src/serena/__init__.py b/src/serena/__init__.py index 2be474ad..eaa65f49 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.5.2.dev0" +__version__ = "1.5.4.dev0" import logging diff --git a/src/serena/agent.py b/src/serena/agent.py index 7b174f87..a1db940a 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -663,19 +663,26 @@ class SerenaAgent: # create the dashboard backend (if enabled), which will register callback. dashboard_api: SerenaDashboardAPI | None = None if self.serena_config.web_dashboard: - dashboard_api = SerenaDashboardAPI(get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats) + dashboard_api = SerenaDashboardAPI( + get_memory_log_handler(), + tool_names, + agent=self, + tool_usage_stats=self._tool_usage_stats, + host=self.serena_config.web_dashboard_listen_address, + trusted_hosts=self.serena_config.web_dashboard_trusted_hosts, + ) # propagate the initial config/state to listeners, particularly to the dashboard API. # Note: The only ongoing task can be the LS initialisation, which does not change the config # This is executed before starting the dashboard thread to ensure that the dashboard - # has the correct initial state before can requests come in. + # has the correct initial state before requests can come in. self._on_config_changed() # start the dashboard backend thread and the dashboard frontend manager (if enabled). # This should be the last thing to happen in the initialization since the dashboard # may access various parts of the agent if dashboard_api: - dashboard_thread, port = dashboard_api.run_in_thread(host=self.serena_config.web_dashboard_listen_address) + dashboard_thread, port = dashboard_api.run_in_thread() self._dashboard_manager = DashboardManager( port, self.serena_config.web_dashboard_listen_address, diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index ff63f523..7207a9b1 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -720,6 +720,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): web_dashboard_open_on_launch: bool = True web_dashboard_interface: str | None = None web_dashboard_listen_address: str = "127.0.0.1" + web_dashboard_trusted_hosts: list[str] = field(default_factory=lambda: ["127.0.0.1", "localhost"]) jetbrains_plugin_server_address: str = "127.0.0.1" tool_timeout: float = DEFAULT_TOOL_TIMEOUT diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 234245d3..7b599f9c 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -197,11 +197,16 @@ class SerenaDashboardAPI: tool_names: list[str], agent: "SerenaAgent", tool_usage_stats: ToolUsageStats | None = None, + host: str = "127.0.0.1", + trusted_hosts: list[str] | None = None, ) -> None: self._memory_log_handler = memory_log_handler self._tool_names = tool_names self._agent = agent + self._host = host self._app = Flask(__name__) + if trusted_hosts: + self._app.config["TRUSTED_HOSTS"] = trusted_hosts self._tool_usage_stats = tool_usage_stats self._loaded_news: dict[str, str] = {} self._news_ready = threading.Event() @@ -777,7 +782,7 @@ class SerenaDashboardAPI: raise RuntimeError(f"No free ports found starting from {start_port}") - def run(self, host: str, port: int) -> int: + def run(self, port: int) -> int: """ Runs the dashboard on the given host and port and returns the port number. """ @@ -785,14 +790,13 @@ class SerenaDashboardAPI: from flask import cli cli.show_server_banner = lambda *args, **kwargs: None - - self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) + self._app.run(host=self._host, port=port, debug=False, use_reloader=False, threaded=True) return port - def run_in_thread(self, host: str) -> tuple[threading.Thread, int]: - port = self._find_first_free_port(self.BASE_PORT, host) - log.info("Starting dashboard (listen_address=%s, port=%d)", host, port) - thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True) + def run_in_thread(self) -> tuple[threading.Thread, int]: + port = self._find_first_free_port(self.BASE_PORT, self._host) + log.info("Starting dashboard (listen_address=%s, port=%d)", self._host, port) + thread = threading.Thread(target=lambda: self.run(port=port), daemon=True) thread.start() return thread, port diff --git a/src/serena/hooks.py b/src/serena/hooks.py index d9a9b03f..ef5e2106 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -272,6 +272,8 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook): #: file suffixes for source-like files where symbolic tools are usually more #: appropriate than repeated raw reads. Lowercase and extension-only. + #: Note: ``search_for_pattern`` is always available regardless of extension and + #: is a better alternative to repeated raw reads for any structured file type. _CODE_FILE_EXTENSIONS: frozenset[str] = frozenset( ( ".al", @@ -289,14 +291,19 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook): ".fs", ".fsx", ".go", + ".graphql", + ".gql", ".groovy", ".h", + ".hcl", ".hpp", ".hs", ".html", ".java", ".jl", ".js", + ".json", + ".jsonc", ".jsx", ".kt", ".kts", @@ -305,6 +312,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook): ".m", ".matlab", ".php", + ".proto", ".ps1", ".py", ".r", @@ -313,11 +321,17 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook): ".scala", ".sh", ".sol", + ".sql", ".svelte", ".swift", + ".tf", + ".tfvars", + ".toml", ".ts", ".tsx", ".vue", + ".yaml", + ".yml", ".zig", ) ) diff --git a/src/serena/project.py b/src/serena/project.py index f202af96..2c1558a8 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -191,8 +191,8 @@ class Project(ToStringMixin): def _is_ignored_relative_path(self, relative_path: str | Path, ignore_non_source_files: bool = True) -> bool: """ - Determine whether an existing path should be ignored based on file type and ignore patterns. - Raises `FileNotFoundError` if the path does not exist. + Determine whether a path should be ignored based on file type and ignore patterns. + Returns False for non-existent paths since they cannot be matched by ignore patterns. :param relative_path: Relative path to check :param ignore_non_source_files: whether files that are not source files (according to the file masks @@ -208,7 +208,8 @@ class Project(ToStringMixin): abs_path = os.path.join(self.project_root, relative_path) if not os.path.exists(abs_path): - raise FileNotFoundError(f"File {abs_path} not found, the ignore check cannot be performed") + log.debug(f"Path {abs_path} does not exist, skipping ignore check") + return False # Check file extension if it's a file is_file = os.path.isfile(abs_path) @@ -284,10 +285,11 @@ class Project(ToStringMixin): def validate_relative_path(self, relative_path: str, require_not_ignored: bool = False) -> None: """ - Validates that the given relative path to an existing file/dir is safe to read or edit, - meaning it's inside the project directory. + Validates that the given relative path is safe to read or edit, + meaning it's inside the project directory and not ignored. - Passing a path to a non-existing file will lead to a `FileNotFoundError`. + Non-existent paths are allowed (not considered ignored) to support + editing tools that create new files. :param relative_path: the path to validate, relative to the project root :param require_not_ignored: if True, the path must not be ignored according to the project's ignore settings diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index a24ed313..8664fea3 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -67,6 +67,14 @@ web_dashboard_interface: # the address the web dashboard will listen on (bind address). web_dashboard_listen_address: 127.0.0.1 +# trusted hosts used to access the web dashboard. +# By default, only allow access via local addresses for security reasons. +# If you want to allow access from remote machines, add the hostname used to access the dashboard to this list. +# If the list is empty/undefined, all hosts are trusted. +web_dashboard_trusted_hosts: + - 127.0.0.1 + - localhost + # address where JetBrains plugin servers are running (only relevant when using the JetBrains language backend) jetbrains_plugin_server_address: 127.0.0.1 diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index 20017630..16a97411 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import os import platform +import shutil import subprocess from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace @@ -154,6 +155,42 @@ class RuntimeDependencyCollection: ) +DEFAULT_UVX_PYTHON_VERSION = "3.13" + + +def build_uvx_launch_command( + package: str, + version: str, + entrypoint: str, + extra_args: Sequence[str] = (), + python_version: str = DEFAULT_UVX_PYTHON_VERSION, +) -> list[str]: + """Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv x``. + + Resolution order: + 1. Prefer ``uvx`` (env var ``UVX`` or PATH lookup). + 2. Fall back to ``uv x`` if only ``uv`` is on PATH. + 3. Raise ``RuntimeError`` if neither is available. + + :param package: PyPI package name (e.g. ``"pyright"``). + :param version: Pinned package version. + :param entrypoint: Console script provided by the package (e.g. ``"pyright-langserver"``). + :param extra_args: Arguments appended after the entrypoint (e.g. ``("--stdio",)``). + :param python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing). + """ + base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint, *extra_args] + + uvx_path = os.environ.get("UVX") or shutil.which("uvx") + if uvx_path is not None: + return [uvx_path, *base_args] + + uv_path = shutil.which("uv") + if uv_path is not None: + return [uv_path, "x", *base_args] + + raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).") + + def build_npm_install_command(package_name: str, version: str, registry: str | None = None) -> list[str]: """Build a pinned npm install command for a package in a Serena-managed install directory.""" command = ["npm", "install", "--prefix", "./"] diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index a80efc68..45d7071b 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -6,19 +6,26 @@ import logging import os import pathlib import re -import shutil from overrides import override from solidlsp import ls_types -from solidlsp.ls import DocumentSymbols, LSPConstants, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls import ( + DocumentSymbols, + LanguageServerDependencyProvider, + LanguageServerDependencyProviderUvx, + LSPConstants, + LSPFileBuffer, + SolidLanguageServer, +) from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +FORTLS_VERSION = "3.2.2" + class FortranLanguageServer(SolidLanguageServer): """Fortran Language Server implementation using fortls.""" @@ -170,24 +177,17 @@ class FortranLanguageServer(SolidLanguageServer): return DocumentSymbols(fixed_root_symbols) - @staticmethod - def _check_fortls_installation() -> str: - """Check if fortls is available.""" - fortls_path = shutil.which("fortls") - if fortls_path is None: - raise RuntimeError("fortls is not installed or not in PATH.\nInstall it with: pip install fortls") - return fortls_path - def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): - # Check fortls installation - fortls_path = self._check_fortls_installation() + super().__init__(config, repository_root_path, None, "fortran", solidlsp_settings) - # Command to start fortls language server - # fortls uses stdio for LSP communication by default - fortls_cmd = f"{fortls_path}" - - super().__init__( - config, repository_root_path, ProcessLaunchInfo(cmd=fortls_cmd, cwd=repository_root_path), "fortran", solidlsp_settings + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="fortls", + entrypoint="fortls", + default_version=FORTLS_VERSION, + version_setting_key="fortls_version", ) @staticmethod diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index 7f774836..d4aa1265 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -6,19 +6,20 @@ import logging import os import pathlib import re -import sys import threading from typing import cast from overrides import override -from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, 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__) +PYRIGHT_VERSION = "1.1.403" + class PyrightServer(SolidLanguageServer): """ @@ -44,14 +45,15 @@ class PyrightServer(SolidLanguageServer): self.found_source_files = False def _create_dependency_provider(self) -> LanguageServerDependencyProvider: - return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - - class DependencyProvider(LanguageServerDependencyProviderSinglePath): - def _get_or_install_core_dependency(self) -> str: - return sys.executable - - def _create_launch_command(self, core_path: str) -> list[str]: - return [core_path, "-m", "pyright.langserver", "--stdio"] + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="pyright", + entrypoint="pyright-langserver", + default_version=PYRIGHT_VERSION, + version_setting_key="pyright_version", + extra_args=("--stdio",), + ) @override def is_ignored_dirname(self, dirname: str) -> bool: diff --git a/src/solidlsp/language_servers/ty_server.py b/src/solidlsp/language_servers/ty_server.py index 0adb8f64..1e0832f0 100644 --- a/src/solidlsp/language_servers/ty_server.py +++ b/src/solidlsp/language_servers/ty_server.py @@ -10,12 +10,11 @@ You can pass the following entries in ``ls_specific_settings["python_ty"]``: import logging import os import pathlib -import shutil from typing import cast from typing_extensions import override -from solidlsp.ls import LanguageServerDependencyProvider, SolidLanguageServer +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings @@ -44,28 +43,15 @@ class TyLanguageServer(SolidLanguageServer): ) def _create_dependency_provider(self) -> LanguageServerDependencyProvider: - return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - - class DependencyProvider(LanguageServerDependencyProvider): - def create_launch_command(self) -> list[str]: - # respecting an explicit override - ls_path = self._custom_settings.get("ls_path") - if ls_path is not None: - return [ls_path, "server"] - - ty_version = self._custom_settings.get("ty_version", TY_VERSION) - - # preferring uvx for on-demand execution - uvx_path = os.environ.get("UVX") or shutil.which("uvx") - if uvx_path is not None: - return [uvx_path, "--from", f"ty=={ty_version}", "ty", "server"] - - # falling back to uv's uvx-compatible subcommand when only `uv` is available - uv_path = shutil.which("uv") - if uv_path is not None: - return [uv_path, "x", "--from", f"ty=={ty_version}", "ty", "server"] - - raise RuntimeError("Could not find 'uvx' or 'uv' in PATH.\nInstall uv or provide ls_specific_settings.python_ty.ls_path.") + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="ty", + entrypoint="ty", + default_version=TY_VERSION, + version_setting_key="ty_version", + extra_args=("server",), + ) @override def is_ignored_dirname(self, dirname: str) -> bool: diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index 2464338a..5525809b 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -34,7 +34,20 @@ DEFAULT_VTSLS_VERSION = "0.2.9" class VtsLanguageServer(SolidLanguageServer): """ Provides TypeScript specific instantiation of the LanguageServer class using vtsls. - Contains various configurations and settings specific to TypeScript via vtsls wrapper. + + Supported entries in ``ls_specific_settings["typescript_vts"]``: + - ``vtsls_version``: version of ``@vtsls/language-server`` to install (default: ``"0.2.9"``). + - ``npm_registry``: custom npm registry for the managed install. + - ``initialization_options``: optional dict forwarded verbatim as LSP + ``initializationOptions``. Useful for Yarn PnP projects, e.g.:: + + initialization_options: + typescript: + tsdk: "project/.yarn/sdks/typescript/lib" + vtsls: + autoUseWorkspaceTsdk: true + + See https://github.com/yioneko/vtsls/issues/169 for the PnP recipe. """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): @@ -113,13 +126,30 @@ class VtsLanguageServer(SolidLanguageServer): assert os.path.exists(vts_executable_path), "vtsls executable not found. Please install @vtsls/language-server and try again." return f"{vts_executable_path} --stdio" - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + @property + def _initialization_options(self) -> dict: + """ + Validated user-provided ``initializationOptions``. + + :raises ValueError: if ``ls_specific_settings.typescript_vts.initialization_options`` + is set to a value that is not a dict. + """ + opts = self._custom_settings.get("initialization_options") + if opts is None: + return {} + if not isinstance(opts, dict): + raise ValueError(f"ls_specific_settings.typescript_vts.initialization_options must be a dict, got {type(opts).__name__}") + return opts + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the VTS Language Server. + + If ``initialization_options`` is set in ``ls_specific_settings["typescript_vts"]``, + it is forwarded verbatim as LSP ``initializationOptions``. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params = { + initialize_params: dict = { "locale": "en", "capabilities": { "textDocument": { @@ -152,6 +182,11 @@ class VtsLanguageServer(SolidLanguageServer): } ], } + + if self._initialization_options: + log.info("Forwarding user-provided initializationOptions to vtsls: %s", self._initialization_options) + initialize_params["initializationOptions"] = self._initialization_options + return cast(InitializeParams, initialize_params) def _start_server(self) -> None: @@ -179,12 +214,12 @@ class VtsLanguageServer(SolidLanguageServer): def execute_client_command_handler(params: dict) -> list: return [] - def workspace_configuration_handler(params: dict) -> list[dict] | dict: - # VTS may request workspace configuration - # Return empty configuration for each requested item - if "items" in params: - return [{}] * len(params["items"]) - return {} + init_options = self._initialization_options + + def workspace_configuration_handler(params: dict) -> list[object]: + # vtsls pulls settings for sections like "typescript", "vtsls", "javascript". + # Return the matching sub-dicts from the user-provided initialization_options. + return [init_options.get(item.get("section", ""), {}) for item in params["items"]] def do_nothing(params: dict) -> None: return @@ -227,6 +262,14 @@ class VtsLanguageServer(SolidLanguageServer): log.debug(f"completionProvider: {init_response['capabilities']['completionProvider']}") self.server.notify.initialized({}) + + # vtsls also reads settings via workspace/didChangeConfiguration (in addition + # to initializationOptions and workspace/configuration pulls). Push the same + # user-provided settings on all three channels for maximum compatibility, + # e.g. so that `typescript.tsdk` is honoured for Yarn PnP projects. + if init_options: + self.server.notify.workspace_did_change_configuration({"settings": init_options}) + if self.server_ready.wait(timeout=1.0): log.info("VTS server is ready") else: diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 64bf112c..ecf1dea7 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -8,7 +8,7 @@ import shutil import threading from abc import ABC, abstractmethod from collections import defaultdict -from collections.abc import Callable, Hashable, Iterator +from collections.abc import Callable, Hashable, Iterator, Sequence from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath @@ -22,6 +22,7 @@ from sensai.util.string import ToStringMixin from serena.util.file_system import match_path from serena.util.text_utils import MatchedConsecutiveLines from solidlsp import ls_types +from solidlsp.language_servers.common import build_uvx_launch_command from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer @@ -333,6 +334,48 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide """ +class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider): + """ + Dependency provider for language servers distributed as a PyPI package, run on demand via ``uvx`` / ``uv x``. + + The pinned package version can be overridden by the user via the LS-specific setting given by + ``version_setting_key``. Alternatively, the LS-specific setting "ls_path" can be set to the path of an + already-installed language server executable, in which case it is launched directly, bypassing uv entirely. + """ + + def __init__( + self, + custom_settings: "SolidLSPSettings.CustomLSSettings", + ls_resources_dir: str, + *, + package: str, + entrypoint: str, + default_version: str, + version_setting_key: str, + extra_args: Sequence[str] = (), + ): + """ + :param package: the PyPI package name (e.g. ``"pyright"``) + :param entrypoint: the console script provided by the package (e.g. ``"pyright-langserver"``) + :param default_version: the package version to pin unless overridden + :param version_setting_key: the LS-specific setting key through which the user can override the version + :param extra_args: arguments appended after the entrypoint (e.g. ``("--stdio",)``) + """ + super().__init__(custom_settings, ls_resources_dir) + self._package = package + self._entrypoint = entrypoint + self._default_version = default_version + self._version_setting_key = version_setting_key + self._extra_args = tuple(extra_args) + + def create_launch_command(self) -> list[str]: + ls_path = self._custom_settings.get("ls_path") + if ls_path is not None: + return [ls_path, *self._extra_args] + version = self._custom_settings.get(self._version_setting_key, self._default_version) + return build_uvx_launch_command(self._package, version, self._entrypoint, self._extra_args) + + class SolidLanguageServer(ABC): """ High-level abstraction for language server interaction, which wraps the underlying low-level LSP interface diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py index af33ceea..da9fadb9 100644 --- a/test/serena/config/test_global_ignored_paths.py +++ b/test/serena/config/test_global_ignored_paths.py @@ -102,6 +102,31 @@ class TestGlobalIgnoredPaths: assert project.is_ignored_path(str(self.project_path / "debug.log")) assert not project.is_ignored_path(str(self.project_path / "main.py")) + def test_nonexistent_path_not_ignored(self) -> None: + """Non-existent paths should return False (not ignored), not raise FileNotFoundError.""" + project = _create_test_project(self.project_path) + nonexistent = str(self.project_path / "src" / "new_file.py") + assert not os.path.exists(nonexistent) + assert not project.is_ignored_path(nonexistent) + + def test_nonexistent_path_with_ignore_patterns(self) -> None: + """Non-existent paths matching ignore patterns still return False (cannot check).""" + project = _create_test_project( + self.project_path, + global_ignored_paths=["*.log"], + ) + nonexistent = str(self.project_path / "nonexistent.log") + assert not os.path.exists(nonexistent) + # Should not raise, should not match the *.log pattern + assert not project.is_ignored_path(nonexistent) + + def test_validate_relative_path_allows_nonexistent(self) -> None: + """validate_relative_path with require_not_ignored should not raise for non-existent paths.""" + project = _create_test_project(self.project_path) + nonexistent = "src/new_file.py" + # Should not raise + project.validate_relative_path(nonexistent, require_not_ignored=True) + class TestRegisteredProjectGlobalIgnoredPaths: """RegisteredProject.get_project_instance() correctly passes global patterns to Project.""" diff --git a/uv.lock b/uv.lock index 41fab003..0f97a31d 100644 --- a/uv.lock +++ b/uv.lock @@ -421,14 +421,14 @@ wheels = [ [[package]] name = "clr-loader" -version = "0.2.10" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/46/7eea92b6aa2d68af78e049cbecec5f757f1aad44ecdecdc16bbad7eead51/clr_loader-0.3.1.tar.gz", hash = "sha256:2e073e9aaf49d1ae2f56ecba27987ad5fb68be4bcd9dd34a5bed8f0e4e128366", size = 86805, upload-time = "2026-04-18T17:49:44.287Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/61/cf819f8e8bb4d4c74661acf2498ba8d4a296714be3478d21eaabf64f5b9b/clr_loader-0.2.10-py3-none-any.whl", hash = "sha256:ebbbf9d511a7fe95fa28a95a4e04cd195b097881dfe66158dc2c281d3536f282", size = 56483, upload-time = "2026-01-03T23:13:05.439Z" }, + { url = "https://files.pythonhosted.org/packages/5e/da/ec1a6e36624000b6df0dd61183c42342ee5814c073315e802cadaad04d2f/clr_loader-0.3.1-py3-none-any.whl", hash = "sha256:cbad189de20d202a7d621956b0fc38049e13c9bf7ca2923441eff725cd121aa1", size = 55730, upload-time = "2026-04-18T17:49:42.99Z" }, ] [[package]] @@ -671,19 +671,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] -[[package]] -name = "fortls" -version = "3.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "json5" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/2b/db1e5cd07fc9e74a2e4fb8f65946f8fd79ef72211001af00982a04d977d5/fortls-3.2.2.tar.gz", hash = "sha256:b43b2b8cbd447ae848c63b8f008c2df96fd48c3a967b33f6ed64b3421496883b", size = 7085222, upload-time = "2025-03-16T18:03:11.377Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/7e/41a55a49e764243fd72b1ed202a95d9a9193586d8665bdc94e4df92050e4/fortls-3.2.2-py3-none-any.whl", hash = "sha256:b7ae8378ce9741064b8340d3f1fbbbf7ab697285e174a0208fed98485c4edfe3", size = 278359, upload-time = "2025-03-16T18:03:09.969Z" }, -] - [[package]] name = "gitdb" version = "4.0.12" @@ -1135,15 +1122,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/4f/1195bbac8e0c2acc5f740661631d8d750dc38d4a32b23ee5df3cde6f4e0d/joblib-1.5.1-py3-none-any.whl", hash = "sha256:4719a31f054c7d766948dcd83e9613686b27114f190f717cec7eaa2084f8a74a", size = 307746, upload-time = "2025-05-23T12:04:35.124Z" }, ] -[[package]] -name = "json5" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, -] - [[package]] name = "jsonschema" version = "4.26.0" @@ -1616,15 +1594,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - [[package]] name = "overrides" version = "7.7.0" @@ -2235,19 +2204,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/86/637cda4983dc0936b73a385f3906256953ac434537b812814cb0b6d231a2/pyobjc_framework_webkit-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1aaa3bf12c7b68e1a36c0b294d2728e06f2cc220775e6dc4541d5046290e4dc8", size = 50680, upload-time = "2025-11-14T10:07:23.331Z" }, ] -[[package]] -name = "pyright" -version = "1.1.403" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, -] - [[package]] name = "pystray" version = "0.19.5" @@ -2347,14 +2303,15 @@ wheels = [ [[package]] name = "pythonnet" -version = "3.1.0rc0" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "clr-loader" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/59/4295652a9a93a2bd6755ecce2dc32e3d3c704e7b8ef301946ee65c818a59/pythonnet-3.1.0rc0.tar.gz", hash = "sha256:5f0f505e100233d70864b00dc5f5dd760603cfd94ac64da5c8b0e15ed95af939", size = 241025, upload-time = "2026-02-25T10:46:53.632Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/57/da1992e44663b71365c6e842c8d7fa453d4ec45fb99a68cfee5b7e944d3c/pythonnet-3.1.0.tar.gz", hash = "sha256:7b34c382905d10a371509ffafd64cae0416305c28817738a9cd138336f4e9991", size = 250599, upload-time = "2026-05-23T20:30:21.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/3d/a378aa74085fc6f72923d5e39fc30efbc97541cfd49b3fe0a35f20d09ff7/pythonnet-3.1.0rc0-py3-none-any.whl", hash = "sha256:286ccf23d08e1cc2b5200bb91d0ee649436d2e8386fec36a85193c66d0ff8b5b", size = 293150, upload-time = "2026-02-25T10:46:48.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4b/52414f442624d2589f5374a48c08d5ae94f24bea67fc13a20a752884e5b7/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-any.whl", hash = "sha256:698dd88edc198819ad63b624a6ebe76208c7b46e4fe13626f65e484f0358d6ba", size = 217578, upload-time = "2026-05-23T20:30:19.527Z" }, + { url = "https://files.pythonhosted.org/packages/db/67/031124fdcb937c266a3265118525bbf6dc13b8c79786d6a7290aecb6e7bb/pythonnet-3.1.0-cp310.cp311.cp312.cp313.cp314-none-win32.win_amd64.whl", hash = "sha256:7bdd4de03df3547a48122a3989265c8b31d5be0d19dadffa009eec7df8085e0b", size = 1644898, upload-time = "2026-05-23T20:30:16.213Z" }, ] [[package]] @@ -2856,7 +2813,7 @@ wheels = [ [[package]] name = "serena-agent" -version = "1.5.2.dev0" +version = "1.5.4.dev0" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -2866,7 +2823,6 @@ dependencies = [ { name = "dotenv" }, { name = "filelock" }, { name = "flask" }, - { name = "fortls" }, { name = "jinja2" }, { name = "joblib" }, { name = "lsprotocol" }, @@ -2876,7 +2832,6 @@ dependencies = [ { name = "psutil" }, { name = "pydantic" }, { name = "pygls" }, - { name = "pyright" }, { name = "pystray" }, { name = "python-dotenv" }, { name = "python-multipart" }, @@ -2937,7 +2892,6 @@ requires-dist = [ { name = "dotenv", specifier = "==0.9.9" }, { name = "filelock", specifier = "==3.25.2" }, { name = "flask", specifier = "==3.1.3" }, - { name = "fortls", specifier = "==3.2.2" }, { name = "google-genai", marker = "extra == 'google'", specifier = "==1.27.0" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "jinja2", marker = "extra == 'dev'", specifier = "==3.1.6" }, @@ -2955,14 +2909,13 @@ requires-dist = [ { name = "pygls", specifier = "==2.1.1" }, { name = "pyinstrument", marker = "extra == 'dev'", specifier = "==5.1.1" }, { name = "pyjwt", marker = "extra == 'dev'", specifier = "==2.12.0" }, - { name = "pyright", specifier = "==1.1.403" }, { name = "pystray", specifier = "==0.19.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-multipart", specifier = "==0.0.27" }, - { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = "==3.1.0rc0" }, + { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = "==3.1.0" }, { name = "pywebview", specifier = "==6.2" }, { name = "pyyaml", specifier = "==6.0.2" }, { name = "regex", specifier = "==2026.2.28" },