From e750058161c4b1c582f38433ca6845c792a040ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:19:13 +0000 Subject: [PATCH 001/170] Bump starlette from 1.0.0 to 1.0.1 in the uv group across 1 directory Bumps the uv group with 1 update in the / directory: [starlette](https://github.com/Kludex/starlette). Updates `starlette` from 1.0.0 to 1.0.1 - [Release notes](https://github.com/Kludex/starlette/releases) - [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/starlette/compare/1.0.0...1.0.1) --- updated-dependencies: - dependency-name: starlette dependency-version: 1.0.1 dependency-type: direct:production dependency-group: uv ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9a03e553..fe14c6b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ # Exact pins because uvx installs from git, ignoring the lock file. "urllib3==2.7.0", "werkzeug==3.1.7", - "starlette==1.0.0", + "starlette==1.0.1", "python-multipart==0.0.27", "filelock==3.25.2", "cryptography==46.0.7", diff --git a/uv.lock b/uv.lock index 0f97a31d..031797c0 100644 --- a/uv.lock +++ b/uv.lock @@ -2928,7 +2928,7 @@ requires-dist = [ { name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = "==2.0.0" }, { name = "sphinx-toolbox", marker = "extra == 'dev'", specifier = "==3.7.0" }, { name = "sqlalchemy", marker = "extra == 'agno'", specifier = "==2.0.41" }, - { name = "starlette", specifier = "==1.0.0" }, + { name = "starlette", specifier = "==1.0.1" }, { name = "syrupy", marker = "extra == 'dev'", specifier = "==4.9.1" }, { name = "tiktoken", specifier = "==0.12.0" }, { name = "toml-sort", marker = "extra == 'dev'", specifier = "==0.24.2" }, @@ -3406,15 +3406,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.0" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, ] [[package]] From 1931b601efcb0ae1a7c32e0382b3d4085fbaa4a4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 5 Jun 2026 16:46:54 +0200 Subject: [PATCH 002/170] Remove incorrect assertion Fixes #1543 --- src/serena/agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index bef1b201..55e06180 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -1169,7 +1169,6 @@ class SerenaAgent: self.issue_task(init_language_server_manager) def init_jetbrains_ide() -> None: - assert self.serena_config.jetbrains_launch_command is not None try: client = jetbrains_plugin_client.JetBrainsPluginClient.from_project(project, log_warning=False) log.info("Found Serena JetBrains Plugin server: %s", client) From 577931c8cf2a248bc195187261deb2a03fd9528b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 7 Jun 2026 17:14:06 +0200 Subject: [PATCH 003/170] Revise issue template #1549 --- ...ue--bug--performance-problem--question-.md | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md b/.github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md index b4d93da1..a438edfe 100644 --- a/.github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md +++ b/.github/ISSUE_TEMPLATE/issue--bug--performance-problem--question-.md @@ -7,22 +7,15 @@ assignees: '' --- -I have: +Preconditions: -- [ ] read the relevant parts of the documentation and verified that the issue cannot be solved by adjusting configuration -- [ ] understood that the Serena Dashboard can be disabled through the config -- [ ] understood that, by default, a client session will start a separate instance of a Serena server. -- [ ] understood that, for multi-agent setups, the Streamable HTTP/SSE mode should be used. -- [ ] understood that non-project files are ignored using either .gitignore or the corresponding setting in `.serena/project.yml` -- [ ] looked for similar issues and discussions, including closed ones -- [ ] made sure it's an actual issue, not a question (use GitHub Discussions instead). +- [ ] I have made sure it's an actual issue, not a question (use [GitHub Discussions](https://github.com/oraios/serena/discussions) instead). +- [ ] I have consulted the [user guide](https://oraios.github.io/serena/02-usage/) and verified that the issue cannot be resolved by adjusting configuration/following recommended workflows. +- [ ] I have looked for similar issues and discussions, including closed ones. -If you have encountered an actual issue: +Issue details: -- If using language servers (not the JetBrains plugin), - - [ ] I performed ` serena project health-check` - - [ ] I indexed the project as described in the documentation -- [ ] I added sufficient explanation of my setup: the MCP client, the OS, the programming language(s), any config adjustments or relevant project specifics -- [ ] I explained how the issue arose and, where possible, added instructions on how to reproduce it -- [ ] If the issue happens on an open-source project, I have added the link -- [ ] I provided a meaningful title and description +- [ ] I have provided a meaningful title and description. +- [ ] I have explained how the issue arose and, where possible, added instructions on how to reproduce it. +- [ ] I have added details on my setup: Serena version, MCP client, OS, the programming language(s), relevant configuration adjustments and project specifics. +- [ ] If the issue relates to an application of Serena to an open-source project, I have added the link. From b5b9cc4ffc8144cef4064dee09433eb592cfebdc Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" Date: Sun, 7 Jun 2026 19:05:01 +0300 Subject: [PATCH 004/170] Fix find_project_root hijacking git worktrees nested under a Serena project (#1550) * Fix find_project_root hijacking git worktrees nested under a Serena project find_project_root searched all ancestor levels for .serena/project.yml before ever looking for .git. A git worktree whose own .git is a pointer file, nested under a directory that is an explicit Serena project, was resolved to the ancestor project instead of the worktree. With CLI agents that launch via --project-from-cwd (Claude Code, Codex, Gemini), this silently bound the server to the wrong working tree: reads returned stale symbols and edits landed in the parent repo. Walk up in a single pass so the nearest project boundary wins, whether it is a .serena/project.yml or a .git (dir or worktree/submodule pointer file). Same-level behavior is unchanged. Adds a regression test. * Document worktree project-root fix in changelog and usage docs --- CHANGELOG.md | 7 ++++++ docs/02-usage/020_running.md | 4 +++- docs/02-usage/999_additional-usage.md | 2 ++ src/serena/cli.py | 23 +++++++++++++------- test/serena/test_cli_project_commands.py | 27 ++++++++++++++++++++++++ 5 files changed, 54 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75c9309c..c89f63de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* General: + - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` + now walks up in a single pass so the nearest project boundary wins (either a `.serena/project.yml` + or a `.git`, including worktree/submodule pointer files), instead of preferring an ancestor's + `.serena/project.yml` over a closer `.git`. This previously bound CLI agents (Claude Code, Codex, + Gemini) launched from inside a worktree to the parent repo, causing stale reads and misdirected edits. + * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. The dict is forwarded to vtsls via `initializationOptions`, `workspace/didChangeConfiguration`, diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index 468b1d32..84de5490 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -92,7 +92,9 @@ Some useful options include: * `--project `: specify the project to work on by name or path. * `--project-from-cwd`: auto-detect the project from current working directory - (looking for a directory containing `.serena/project.yml` or `.git` in parent directories and activating the containing directory as the project root, if any). + (walking up the parent directories and activating the nearest one that contains either `.serena/project.yml` + or `.git`, if any). The nearest boundary wins, so a git worktree nested under another Serena project resolves + to the worktree itself rather than the ancestor project. This option is intended for CLI-based agents like Claude Code, Gemini and Codex, which are typically started from within the project directory and which do not change directories during their operation. * `--language-backend JetBrains`: use the Serena JetBrains Plugin as the language backend (overriding the default backend configured in the central configuration) diff --git a/docs/02-usage/999_additional-usage.md b/docs/02-usage/999_additional-usage.md index d4da7ce6..0fef1f8a 100644 --- a/docs/02-usage/999_additional-usage.md +++ b/docs/02-usage/999_additional-usage.md @@ -14,3 +14,5 @@ having persisted the plan in a memory or dedicated file. [git-worktree](https://git-scm.com/docs/git-worktree) can be an excellent way to parallelize your work. More on this in [Anthropic: Run parallel Claude Code sessions with Git worktrees](https://docs.claude.com/en/docs/claude-code/common-workflows#run-parallel-claude-code-sessions-with-git-worktrees). Be sure to add the `.serena` folder to version control, such that your project-specific settings and memories are available across worktrees. + +When you launch a CLI agent from inside a worktree using `--project-from-cwd`, Serena activates the worktree itself, even if the worktree lives under another Serena project (for example `/.claude/worktrees/`, where Claude Code creates them natively). The nearest project boundary wins: the worktree's own `.git` pointer file takes precedence over an ancestor's `.serena/project.yml`, so file operations always resolve against the correct working tree. diff --git a/src/serena/cli.py b/src/serena/cli.py index 71d7da38..3b9ba581 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -65,7 +65,16 @@ For details on mode configuration, see def find_project_root(root: str | Path | None = None) -> str | None: """Find project root by walking up from CWD. - Checks for .serena/project.yml first (explicit Serena project), then .git (git root). + Returns the nearest ancestor that is either an explicit Serena project + (contains .serena/project.yml) or a git root (contains .git, which may be a + directory or, for git worktrees and submodules, a pointer file). The nearest + such directory wins; a .serena/project.yml at the same level takes priority + over .git only because they resolve to the same directory. + + Walking up with a single pass (rather than searching all levels for + .serena/project.yml first and only then for .git) ensures a git worktree + nested under another Serena project resolves to the worktree itself, instead + of being hijacked by the ancestor project's .serena/project.yml. :param root: If provided, constrains the search to this directory and below (acts as a virtual filesystem root). Search stops at this boundary. @@ -82,14 +91,12 @@ def find_project_root(root: str | Path | None = None) -> str | None: if boundary is not None and parent == boundary: return - # First pass: look for .serena + # Single pass: the nearest project boundary wins, whether it is an explicit + # Serena project (.serena/project.yml) or a git root (.git, a dir or a worktree + # pointer file). This keeps a nested git worktree from being hijacked by an + # ancestor's .serena/project.yml. for directory in ancestors(): - if (directory / ".serena" / "project.yml").is_file(): - return str(directory) - - # Second pass: look for .git - for directory in ancestors(): - if (directory / ".git").exists(): # .git can be file (worktree) or dir + if (directory / ".serena" / "project.yml").is_file() or (directory / ".git").exists(): return str(directory) return None diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index be90a799..ff6fd7ec 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -328,6 +328,33 @@ class TestFindProjectRoot: finally: os.chdir(original_cwd) + def test_git_worktree_not_hijacked_by_ancestor_serena(self, temp_project_dir): + """A git worktree nested under a Serena project must resolve to the worktree. + + Regression test: when a git worktree (whose .git is a pointer *file*) lives + below a directory that is an explicit Serena project (.serena/project.yml), + the worktree's own .git boundary must win over the ancestor's project marker. + The old two-pass search returned the ancestor Serena project, causing reads + and edits to land in the wrong working tree. + """ + # Ancestor directory is an explicit Serena project. + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + # Nested git worktree: .git is a gitdir pointer file, as created by `git worktree add`. + worktree = os.path.join(temp_project_dir, "nested", "worktree") + os.makedirs(worktree) + Path(os.path.join(worktree, ".git")).write_text("gitdir: /repo/.git/worktrees/wt\n") + + original_cwd = os.getcwd() + try: + os.chdir(worktree) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, worktree) + finally: + os.chdir(original_cwd) + class TestProjectFromCwdMutualExclusivity: """Tests for --project-from-cwd mutual exclusivity.""" From 34e7c93f7894f5e331b40aed7834202bcbc91c1c Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 8 Jun 2026 00:03:49 +0300 Subject: [PATCH 005/170] Fix: never persist runtime config overrides during project registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: `serena start-mcp-server --language-backend X --project ` silently rewrote the global serena_config.yml's language_backend to X. Cause: a not-yet-registered --project is auto-registered, and SerenaConfig.save() rewrote the WHOLE file from the in-memory config — which by then carried the CLI --language-backend override (and would equally carry --log-level / --tool-timeout / etc.). Fix: project (de)registration now persists ONLY the projects list. add_registered_project / remove_project go through a new _persist_projects(), which re-loads the on-disk config, copies in the current project list, and saves that — so a transient runtime override of any field can never be written back. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + src/serena/config/serena_config.py | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c89f63de..48c040e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Status of the `main` branch. Changes prior to the next official version change w or a `.git`, including worktree/submodule pointer files), instead of preferring an ancestor's `.serena/project.yml` over a closer `.git`. This previously bound CLI agents (Claude Code, Codex, Gemini) launched from inside a worktree to the parent repo, causing stale reads and misdirected edits. + - Fix: some CLI flags on start-mcp-server modified the global config (notably, language-backend). * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 03a18e72..92b2060d 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -1035,10 +1035,10 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): def add_registered_project(self, registered_project: RegisteredProject) -> None: """ - Adds a registered project, saving the configuration file. + Adds a registered project, persisting only the updated project list (see :meth:`_persist_projects`). """ self.projects.append(registered_project) - self.save() + self._persist_projects() def add_project_from_path(self, project_root: Path | str) -> "Project": """ @@ -1083,7 +1083,23 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): break else: raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") - self.save() + self._persist_projects() + + def _persist_projects(self) -> None: + """ + Persist ONLY the registered-projects list, leaving every other setting at its on-disk value. + + Project (de)registration can happen while a session is running with transient runtime overrides applied + to this in-memory instance (e.g. ``start-mcp-server --language-backend`` / ``--log-level``). A full + :meth:`save` would write those overrides back to the global config, silently clobbering the user's + settings. Instead we re-load the persisted config, copy in the current project list, and save that — so + only ``projects`` is ever mutated on disk. + """ + if self.config_file_path is None: + return + persisted = SerenaConfig.from_config_file() + persisted.projects = list(self.projects) + persisted.save() def save(self) -> None: """ From a9cf93aaa3202bfa557aab6003f18dc2e63cbd9c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 8 Jun 2026 11:31:10 +0200 Subject: [PATCH 006/170] Improve handling of configuration file updates * Make SerenaConfig.save private, because it is not a method that should ever be called on a runtime instance; it is purely an internal operation * Refactor the CLI init command appropriately (which used the save method) * Improve comments to make usage limitations clear * Improve the changelog entry to make error conditions more concrete --- CHANGELOG.md | 3 ++- src/serena/cli.py | 4 +--- src/serena/config/serena_config.py | 32 +++++++++++++++++++++++------- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c040e0..3ba64b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ Status of the `main` branch. Changes prior to the next official version change w or a `.git`, including worktree/submodule pointer files), instead of preferring an ancestor's `.serena/project.yml` over a closer `.git`. This previously bound CLI agents (Claude Code, Codex, Gemini) launched from inside a worktree to the parent repo, causing stale reads and misdirected edits. - - Fix: some CLI flags on start-mcp-server modified the global config (notably, language-backend). + - Fix: CLI flags on `start-mcp-server` could incorrectly be saved to the global configuration file if the + list of projects was modified (triggering a save of the configuration with transient overrides applied) * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/cli.py b/src/serena/cli.py index 3b9ba581..98b8ed66 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -184,9 +184,7 @@ class TopLevelCommands(AutoRegisteringGroup): ) def init(language_backend: Literal["LSP", "JetBrains"] = "LSP") -> None: click.echo(f"\nSerena version: {serena_version()}\n") - serena_config = SerenaConfig.from_config_file() - serena_config.language_backend = LanguageBackend(language_backend) - serena_config.save() + serena_config = SerenaConfig.init(language_backend=LanguageBackend(language_backend)) click.echo(f"Configuration file: {serena_config.config_file_path}") click.echo(f"Language backend: {language_backend}") diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 92b2060d..15499fe3 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -957,7 +957,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): # re-save the configuration file if any migrations were performed if num_migrations > 0: log.info("Legacy configuration was migrated; re-saving configuration file") - instance.save() + instance._save() return instance @@ -985,6 +985,20 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): log.error(f"Error migrating configuration file: {e}") return None + @classmethod + def init(cls, language_backend: LanguageBackend) -> "SerenaConfig": + """ + Supports the config initialisation CLI command, allowing the user to configure fundamental settings before + the first launch. + + :param language_backend: the language backend to use + :return: the created SerenaConfig instance + """ + config = cls.from_config_file() + config.language_backend = language_backend + config._save() + return config + @cached_property def project_paths(self) -> list[str]: return sorted(str(project.project_root) for project in self.projects) @@ -1035,14 +1049,14 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): def add_registered_project(self, registered_project: RegisteredProject) -> None: """ - Adds a registered project, persisting only the updated project list (see :meth:`_persist_projects`). + Adds a registered project, persisting the updated project list """ self.projects.append(registered_project) self._persist_projects() def add_project_from_path(self, project_root: Path | str) -> "Project": """ - Add a new project to the Serena configuration from a given path, auto-generating the project + Adds a new project to the Serena configuration from a given path, auto-generating the project with defaults if it does not exist. Will raise a FileExistsError if a project already exists at the path. @@ -1087,7 +1101,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): def _persist_projects(self) -> None: """ - Persist ONLY the registered-projects list, leaving every other setting at its on-disk value. + Persists ONLY the registered-projects list, leaving every other setting at its on-disk value. Project (de)registration can happen while a session is running with transient runtime overrides applied to this in-memory instance (e.g. ``start-mcp-server --language-backend`` / ``--log-level``). A full @@ -1099,11 +1113,15 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): return persisted = SerenaConfig.from_config_file() persisted.projects = list(self.projects) - persisted.save() + persisted._save() - def save(self) -> None: + def _save(self) -> None: """ - Saves the configuration to the file from which it was loaded (if any) + Saves the full configuration to the file from which it was loaded (if any) + + NOTE: This method is private, because it is not usually safe to save a configuration instance used + at runtime, because it often contains transient overrides (e.g. specified through the CLI) + that should never be persisted back to the configuration file. """ if self.config_file_path is None: return From 79bc9d91cc79b291c55c62a00c3d6dc95e32dcb6 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 8 Jun 2026 16:15:53 +0300 Subject: [PATCH 007/170] Fix Svelte (svelte-only) symbol discovery in .ts/.js files (#1552) Bug: with `languages: [svelte]` (the documented setup), find_symbol/get_symbols_overview returned empty for .ts/.js files, and find_referencing_symbols failed with "No symbol matching '' found". Symbols living in plain .ts/.js modules (services, stores, types, utils) were undiscoverable through Serena's symbolic tools. Cause: SvelteLanguageServer delegated references/definition/rename/diagnostics for .ts/.js files to the companion (svelte-plugin-aware) TS server, but had no override for document-symbol requests. So documentSymbol on a .ts/.js file was answered by the base svelte LS, which only serves .svelte files, and returned nothing. Since find_symbol/get_symbols_overview are backed by documentSymbol -- and find_referencing_symbols must first locate the target symbol the same way -- the discovery step never reached the companion. Fix: override request_document_symbols to route .ts/.js files to the companion TS server, mirroring the existing delegations. This is the single method that all symbol-tree/overview/containing-symbol paths flow through, so it restores discovery for find_symbol, get_symbols_overview, find_referencing_symbols and symbol-based editing in one place. Tests: add .ts document-symbol and overview coverage plus agent-level symbol-retriever tests; strengthen the existing symbol-tree test to assert GAME_VERSION, a symbol defined only in a .ts file (the prior assertions also matched .svelte definitions, which is why the regression went unnoticed). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++ .../svelte_language_server.py | 21 ++++++ test/solidlsp/svelte/test_svelte_basic.py | 30 +++++++++ test/solidlsp/svelte/test_svelte_symbols.py | 67 +++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 test/solidlsp/svelte/test_svelte_symbols.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba64b04..056390fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Status of the `main` branch. Changes prior to the next official version change w at the Yarn-generated SDK. - `SvelteLanguageServer`: Fix diagnostics requests for TypeScript/JavaScript files incorrectly being processed by the Svelte LS instead of the TypeScript LS. + - `SvelteLanguageServer`: Fix document-symbol requests for TypeScript/JavaScript files returning empty + results in svelte-only mode (`languages: [svelte]`). They are now routed to the companion TS server, + so symbols defined in plain `.ts`/`.js` files are again discoverable via `find_symbol`/ + `get_symbols_overview`, and `find_referencing_symbols` no longer fails to locate `.ts`/`.js` symbols. #1552 - Improve quoting of arguments in shell executions * JetBrains: diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index c633d10f..a0cff5b1 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -21,8 +21,10 @@ from solidlsp.language_servers.common import ( ) from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer from solidlsp.ls import ( + DocumentSymbols, LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, SolidLanguageServer, ) from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig @@ -647,6 +649,25 @@ class SvelteLanguageServer(SolidLanguageServer): return self._ts_server.request_definition(relative_file_path, line, column) return super().request_definition(relative_file_path, line, column) + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + """Delegate document-symbol requests for .ts/.js files to the companion TS server. + + The base svelte LS only provides ``documentSymbol`` for .svelte files and returns nothing for + .ts/.js files. Without this override, symbols defined in plain .ts/.js modules would be + undiscoverable via ``find_symbol``/``get_symbols_overview`` and ``find_referencing_symbols`` + would fail outright, since it must first locate the target symbol via ``documentSymbol``. + Routing .ts/.js files to the companion (svelte-plugin-aware) typescript LS mirrors the + existing references/definition/rename delegations. + + The companion's own ``file_buffer`` is not reused here: the provided buffer (if any) is bound + to this svelte LS, so the companion opens and reads the file itself. + Falls back to the svelte LS when the companion is unavailable or when the file is .svelte. + """ + if _is_ts_file(relative_file_path) and self._ts_server is not None: + return self._ts_server.request_document_symbols(relative_file_path) + return super().request_document_symbols(relative_file_path, file_buffer) + @override def request_text_document_diagnostics( self, diff --git a/test/solidlsp/svelte/test_svelte_basic.py b/test/solidlsp/svelte/test_svelte_basic.py index 6540d1b8..e49ebd0b 100644 --- a/test/solidlsp/svelte/test_svelte_basic.py +++ b/test/solidlsp/svelte/test_svelte_basic.py @@ -30,6 +30,12 @@ class TestSvelteLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "Game"), "Game class not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "words"), "words export not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "count"), "count export not found in symbol tree" + # GAME_VERSION is defined *only* in a .ts file (game.ts); it is merely imported elsewhere. + # It therefore guards against the regression where .ts document symbols are not routed to the + # companion TS server (unlike game/Game/words/count, which also appear in .svelte files). + assert SymbolUtils.symbol_tree_contains_name(symbols, "GAME_VERSION"), ( + "GAME_VERSION (defined only in a .ts file) not found in symbol tree" + ) @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) def test_document_symbols_inside_svelte_file(self, language_server: SolidLanguageServer) -> None: @@ -40,6 +46,30 @@ class TestSvelteLanguageServer: assert "offset" in symbol_names assert "modulo" in symbol_names + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_document_symbols_inside_typescript_file(self, language_server: SolidLanguageServer) -> None: + # document symbols of a plain .ts file must be served by the companion TS server, not the + # base svelte LS (which only provides documentSymbol for .svelte files); see issue #1552. + file_path = os.path.join("src", "lib", "game.ts") + symbol_names = {symbol["name"] for symbol in language_server.request_document_symbols(file_path).iter_symbols()} + + # top-level symbols + assert "GAME_VERSION" in symbol_names, symbol_names + assert "Game" in symbol_names, symbol_names + # members nested inside the Game class + assert "enter" in symbol_names, symbol_names + assert "toString" in symbol_names, symbol_names + + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) + def test_overview_of_typescript_file(self, language_server: SolidLanguageServer) -> None: + # get_symbols_overview on a .ts file must not be empty in svelte-only mode (issue #1552). + file_path = os.path.join("src", "lib", "game.ts") + overview = language_server.request_overview(file_path) + + top_level_names = {symbol["name"] for symbols in overview.values() for symbol in symbols} + assert "GAME_VERSION" in top_level_names, overview + assert "Game" in top_level_names, overview + @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) def test_definition_from_component_import_to_svelte_file(self, language_server: SolidLanguageServer) -> None: file_path = os.path.join("src", "lib", "components", "Header.svelte") diff --git a/test/solidlsp/svelte/test_svelte_symbols.py b/test/solidlsp/svelte/test_svelte_symbols.py new file mode 100644 index 00000000..c6171d4b --- /dev/null +++ b/test/solidlsp/svelte/test_svelte_symbols.py @@ -0,0 +1,67 @@ +"""Agent-level symbol discovery/navigation for plain .ts files of a svelte-only project. + +Reproduces https://github.com/oraios/serena/issues/1552: in ``languages: [svelte]`` mode, +document-symbol requests for .ts/.js files were answered by the base svelte LS (which only +serves .svelte files) instead of being routed to the companion, svelte-plugin-aware TS server. +As a consequence, symbols defined in plain .ts files were undiscoverable through ``find_symbol``/ +``get_symbols_overview``, and ``find_referencing_symbols`` failed with ``No symbol matching ...`` +because it must first locate the target symbol via ``documentSymbol``. + +These tests exercise the high-level :class:`LanguageServerSymbolRetriever` that backs those agent +tools, complementing the lower-level checks in ``test_svelte_basic.py``. +""" + +import os + +import pytest + +from serena.project import Project +from serena.symbol import LanguageServerSymbolRetriever +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind + +pytestmark = pytest.mark.svelte + +# a plain .ts module whose exported symbols are consumed by both .ts and .svelte files +GAME_TS = os.path.join("src", "lib", "game.ts") + + +class TestSvelteTypeScriptSymbolDiscovery: + @pytest.mark.parametrize("project_with_ls", [Language.SVELTE], indirect=True) + def test_find_symbol_in_typescript_file(self, project_with_ls: Project) -> None: + retriever = LanguageServerSymbolRetriever(project_with_ls) + + # GAME_VERSION is *defined* only in game.ts (merely imported elsewhere) + version_symbols = retriever.find("GAME_VERSION", within_relative_path=GAME_TS) + assert [s.name for s in version_symbols] == ["GAME_VERSION"] + assert version_symbols[0].relative_path is not None + assert version_symbols[0].relative_path.replace("\\", "/") == "src/lib/game.ts" + + # the Game class is discoverable and correctly typed + game_symbols = retriever.find("Game", within_relative_path=GAME_TS, include_kinds=[SymbolKind.Class]) + assert len(game_symbols) == 1, game_symbols + assert game_symbols[0].symbol_kind == SymbolKind.Class + + # nested members of the .ts class are discoverable via their name path + enter_symbols = retriever.find("Game/enter", within_relative_path=GAME_TS) + assert len(enter_symbols) == 1, enter_symbols + assert enter_symbols[0].get_name_path() == "Game/enter" + + @pytest.mark.parametrize("project_with_ls", [Language.SVELTE], indirect=True) + def test_find_referencing_symbols_locates_typescript_symbol(self, project_with_ls: Project) -> None: + retriever = LanguageServerSymbolRetriever(project_with_ls) + + # The core of issue #1552 for find-references: the target symbol must first be located via + # documentSymbol. Before the fix, .ts document symbols were empty, so locating the symbol + # raised ValueError("No symbol matching 'GAME_VERSION' found") *before the companion TS + # server was ever consulted*. Verify the symbol is now uniquely discoverable. + symbol = retriever.find_unique("GAME_VERSION", within_relative_path=GAME_TS) + assert symbol.name == "GAME_VERSION" + assert symbol.relative_path is not None + assert symbol.relative_path.replace("\\", "/") == "src/lib/game.ts" + + # The end-to-end find_referencing_symbols call must now get past symbol discovery without + # raising. (The cross-file/.svelte reference *content* is covered by test_svelte_references.py + # and depends on the companion TS server's reference graph rather than on this fix.) + references = retriever.find_referencing_symbols("GAME_VERSION", GAME_TS) + assert isinstance(references, list) From e4af86be058b233f62eeea970e4e34b1c5cac8e1 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 9 Jun 2026 12:05:03 +0200 Subject: [PATCH 008/170] Fix: Incorrect file access when loading context/mode Context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); file access is now guarded with path detection (file ending or path separator must be present) Fixes #1558 --- CHANGELOG.md | 3 +++ src/serena/config/context_mode.py | 37 ++++++++++++++++--------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 056390fa..6046633a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Status of the `main` branch. Changes prior to the next official version change w Gemini) launched from inside a worktree to the parent repo, causing stale reads and misdirected edits. - Fix: CLI flags on `start-mcp-server` could incorrectly be saved to the global configuration file if the list of projects was modified (triggering a save of the configuration with transient overrides applied) + - Fix: context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in + incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); + file access is now guarded with path detection (file ending or path separator must be present) * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index bb3164c9..5d42d650 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -26,6 +26,10 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) +def looks_like_yaml_path(s: str) -> bool: + return os.sep in s or (os.altsep and os.altsep in s) or s.lower().endswith((".yml", ".yaml")) + + @dataclass(kw_only=True) class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): """Represents a mode of operation for the agent, typically read off a YAML file. @@ -124,16 +128,15 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def load(cls, name_or_path: str | Path) -> Self: - # Check if it's a file path that exists - path = Path(name_or_path) - if path.exists() and path.is_file(): - return cls.from_yaml(name_or_path) - - # If it looks like a file path but doesn't exist, raise FileNotFoundError - name_or_path_str = str(name_or_path) - if os.sep in name_or_path_str or (os.altsep and os.altsep in name_or_path_str) or name_or_path_str.endswith((".yml", ".yaml")): - raise FileNotFoundError(f"Mode file not found: {path.resolve()}") + # If it is a path or looks like a path, load from file + if isinstance(name_or_path, Path) or looks_like_yaml_path(str(name_or_path)): + path = Path(name_or_path) + if path.exists() and path.is_file(): + return cls.from_yaml(name_or_path) + else: + raise FileNotFoundError(f"Mode file not found: {path.resolve()}") + # load from name return cls.from_name(str(name_or_path)) def has_prompt(self) -> bool: @@ -239,15 +242,13 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def load(cls, name_or_path: str | Path) -> Self: - # Check if it's a file path that exists - path = Path(name_or_path) - if path.exists() and path.is_file(): - return cls.from_yaml(name_or_path) - - # If it looks like a file path but doesn't exist, raise FileNotFoundError - name_or_path_str = str(name_or_path) - if os.sep in name_or_path_str or (os.altsep and os.altsep in name_or_path_str) or name_or_path_str.endswith((".yml", ".yaml")): - raise FileNotFoundError(f"Context file not found: {path.resolve()}") + # If it is a path or looks like a path, load from file + if isinstance(name_or_path, Path) or looks_like_yaml_path(str(name_or_path)): + path = Path(name_or_path) + if path.exists() and path.is_file(): + return cls.from_yaml(name_or_path) + else: + raise FileNotFoundError(f"Context file not found: {path.resolve()}") return cls.from_name(str(name_or_path)) From 572e00e6d4e5c7bbd7191d6bcc9d7b00da7286ca Mon Sep 17 00:00:00 2001 From: Sergiy Borodych Date: Tue, 2 Jun 2026 08:25:23 +0300 Subject: [PATCH 009/170] Allow query_project to use read-only tools even if they are excluded by the current context --- CHANGELOG.md | 1 + src/serena/tools/query_project_tools.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6046633a..01c8e759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); file access is now guarded with path detection (file ending or path separator must be present) + - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/tools/query_project_tools.py b/src/serena/tools/query_project_tools.py index 2820ab29..7d845a22 100644 --- a/src/serena/tools/query_project_tools.py +++ b/src/serena/tools/query_project_tools.py @@ -53,7 +53,6 @@ class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveP :param tool_params_json: the parameters to pass to the tool, encoded as a JSON string """ tool = self.agent.get_tool_by_name(tool_name) - assert tool.is_active(), f"Tool {tool_name} is not active." assert tool.is_readonly(), f"Tool {tool_name} is not read-only and cannot be executed in another project." if self._is_project_server_required(tool): client = ProjectServerClient() From dc9ea7a1c8528bf3475268575fb38768a53256a1 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 10 Jun 2026 15:57:13 +0200 Subject: [PATCH 010/170] Fix: JetBrains mode prompt was not provided to agents The mode is now treated as a (background) base mode in `ActiveModes` which reduces the surface for issues pertaining to custom handling of modes. --- CHANGELOG.md | 2 ++ src/serena/agent.py | 66 ++++++++++++++++++++++++----------------- src/serena/dashboard.py | 2 +- 3 files changed, 42 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c8e759..df802ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Status of the `main` branch. Changes prior to the next official version change w incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); file access is now guarded with path detection (file ending or path separator must be present) - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration + - Fix: JetBrains mode prompt was not provided to agents; The mode is now treated as a (background) base mode + in `ActiveModes` which reduces the surface for issues pertaining to custom handling of modes. * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/agent.py b/src/serena/agent.py index 55e06180..5a151377 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -210,7 +210,12 @@ class ToolSet: class ActiveModes: _mode_instances: dict[str, SerenaAgentMode] = {} - def __init__(self) -> None: + def __init__(self, background_base_modes: Sequence[SerenaAgentMode] | None = None) -> None: + """ + :param background_base_modes: base modes that are always active in the background and will not be removed + by applications of mode selection definitions via method `apply` + """ + self._background_base_modes = background_base_modes or [] self._configured_base_modes: Sequence[str] | None = None self._configured_default_modes: Sequence[str] | None = None self._added_modes: set[str] = set() @@ -220,7 +225,7 @@ class ActiveModes: """ self._active_mode_names: Sequence[str] = [] """ - the full list of active mode names + the full list of active mode names (not including background base modes) """ def apply(self, mode_selection: ModeSelectionDefinition) -> None: @@ -245,6 +250,9 @@ class ActiveModes: self._active_mode_names = sorted(set(self._configured_base_modes or []) | self._dynamically_activated_mode_names) def get_mode_names(self) -> Sequence[str]: + """ + :return: the ordered list of active mode names (not including the non-user-facing background base modes) + """ return self._active_mode_names @classmethod @@ -253,14 +261,22 @@ class ActiveModes: cls._mode_instances[mode_name] = SerenaAgentMode.load(mode_name) return cls._mode_instances[mode_name] - def get_modes(self) -> Sequence[SerenaAgentMode]: - return [self.get_mode_instance(mode_name) for mode_name in self._active_mode_names] + def get_modes(self, include_background_base_modes: bool = False) -> Sequence[SerenaAgentMode]: + result: list[SerenaAgentMode] = [] + if include_background_base_modes: + result.extend(self._background_base_modes) + result.extend([self.get_mode_instance(mode_name) for mode_name in self._active_mode_names]) + return result def get_dynamically_activated_modes(self) -> Sequence[SerenaAgentMode]: return [self.get_mode_instance(mode_name) for mode_name in self._dynamically_activated_mode_names] - def get_base_modes(self) -> Sequence[SerenaAgentMode]: - return [self.get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []] + def get_base_modes(self, include_background_base_modes: bool = False) -> Sequence[SerenaAgentMode]: + result: list[SerenaAgentMode] = [] + if include_background_base_modes: + result.extend(self._background_base_modes) + result.extend([self.get_mode_instance(mode_name) for mode_name in self._configured_base_modes or []]) + return result class ProjectPromptProvisionStatus: @@ -555,10 +571,6 @@ class SerenaAgent: # propagate configuration to other components self.serena_config.propagate_settings() - # initialise active modes (baseline modes prior to project activation) - self._active_modes: ActiveModes - self._update_active_modes(log_message=False) - # determine registered project to be activated (if any) registered_project_to_activate: RegisteredProject | None = ( self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None @@ -642,6 +654,10 @@ class SerenaAgent: # Initialize the prompt factory self.prompt_factory = SerenaPromptFactory() + # initialise active modes (baseline modes prior to project activation, allowing newly activated modes to be tracked) + self._active_modes: ActiveModes + self._update_active_modes(log_message=False) + # activate the given project (if any), also updating the active modes # Note: We cannot update the active tools yet, because the base toolset has not been computed yet # (and its computation depends on the active project) @@ -653,9 +669,7 @@ class SerenaAgent: self._update_active_modes() # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), - self._base_toolset = self._create_base_toolset( - self.serena_config, self._language_backend, self._context, self._active_modes, self._active_project - ) + self._base_toolset = self._create_base_toolset(self.serena_config, self._context, self._active_modes, self._active_project) self._exposed_tools = self._base_toolset.to_available_tools(self._all_tools) log.info(f"Number of exposed tools: {len(self._exposed_tools)}. Exposed tools: {self._exposed_tools.tool_names}") @@ -719,7 +733,6 @@ class SerenaAgent: def _create_base_toolset( cls, serena_config: SerenaConfig, - language_backend: LanguageBackend, context: SerenaAgentContext, modes: ActiveModes, project: Project | None, @@ -730,9 +743,9 @@ class SerenaAgent: * dashboard availability/opening on launch * Serena config * the context (which is fixed for the session) - * the optional tools enabled by initial modes + * the base modes (including background base modes like JetBrains mode) + * the optional tools enabled by initial dynamic modes * single-project mode reductions (if applicable) - * JetBrains mode """ # determine whether to include the OpenDashboardTool based on the Serena configuration tool_inclusion_definitions: list[ToolInclusionDefinition] = [] @@ -751,7 +764,7 @@ class SerenaAgent: # consider modes # * base modes: These cannot be changed, so they are fully applied - for base_mode in modes.get_base_modes(): + for base_mode in modes.get_base_modes(include_background_base_modes=True): tool_inclusion_definitions.append(base_mode) # * dynamically activated modes: # - When not in a single-project context, these modes can later be turned off, @@ -788,10 +801,6 @@ class SerenaAgent: ) tool_inclusion_definitions.append(project.project_config) - # enabled the internal 'jetbrains' mode for the JetBrains backend - if language_backend == LanguageBackend.JETBRAINS: - tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) - # compute the resulting tool set base_toolset = ToolSet.default().apply(*tool_inclusion_definitions) log.info(f"Number of exposed tools: {len(base_toolset)}") @@ -912,11 +921,11 @@ class SerenaAgent: raise ValueError("No active project. Please activate a project first.") return project - def get_active_modes(self) -> list[SerenaAgentMode]: + def get_active_modes(self) -> ActiveModes: """ - :return: the list of active modes + :return: the active modes """ - return list(self._active_modes.get_modes()) + return self._active_modes def _format_prompt(self, prompt_template: str) -> str: template = JinjaTemplate(prompt_template) @@ -948,7 +957,7 @@ class SerenaAgent: # determine modes for which prompts must (still) be provided, excluding modes that were already provided in a # previously provided project activation message (if any) relevant_modes = [] - for mode in self.get_active_modes(): + for mode in self.get_active_modes().get_modes(include_background_base_modes=True): if mode.has_prompt(): if not self._project_prompt_status.is_mode_prompt_already_provided(mode.name, session_id): relevant_modes.append(mode) @@ -1027,7 +1036,10 @@ class SerenaAgent: Updates the active modes based on the Serena configuration, the active project configuration (if any), and mode overrides (if any). """ - self._active_modes = ActiveModes() + background_base_modes = [] + if self._language_backend.is_jetbrains(): + background_base_modes.append(SerenaAgentMode.from_name_internal("jetbrains")) + self._active_modes = ActiveModes(background_base_modes=background_base_modes) self._active_modes.apply(self.serena_config) if self._active_project: self._active_modes.apply(self._active_project.project_config) @@ -1260,7 +1272,7 @@ class SerenaAgent: result_str += f"Active context: {self._context.name}\n" # Active modes - active_mode_names = [mode.name for mode in self.get_active_modes()] + active_mode_names = self.get_active_modes().get_mode_names() result_str += "Active modes: {}\n".format(", ".join(active_mode_names)) + "\n" # Available but not active modes diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index cac54bc5..f424ca04 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -502,7 +502,7 @@ class SerenaDashboardAPI: } # Get active modes - modes = self._agent.get_active_modes() + modes = self._agent.get_active_modes().get_modes(include_background_base_modes=False) modes_info = [ {"name": mode.name, "description": mode.description, "path": SerenaAgentMode.get_path(mode.name, instance=mode)} for mode in modes From ba221e72b313b620a168b7bb94a6fdb91bab1e3e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 11 Jun 2026 16:10:53 +0200 Subject: [PATCH 011/170] Use oslex to quote/escape command/arg lists in convert_shell_cmd Even though lists are explicitly supported on Windows, the subprocess implementation does not guarantee that shell metacharacters are appropriately escaped, i.e. the individual list elements are not necessarily treated as atomic arguments. oslex provides an abstraction layer which works across platforms. --- CHANGELOG.md | 4 ++++ pyproject.toml | 1 + src/solidlsp/util/subprocess_util.py | 13 ++++++------- uv.lock | 23 +++++++++++++++++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df802ac9..5ba7a287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration - Fix: JetBrains mode prompt was not provided to agents; The mode is now treated as a (background) base mode in `ActiveModes` which reduces the surface for issues pertaining to custom handling of modes. + - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. @@ -43,6 +44,9 @@ Status of the `main` branch. Changes prior to the next official version change w - Tray manager: Add NixOS-support for AppIndicator-based trays (e.g., most Wayland-trays) to the package in flake.nix. - Fix: Wait for the subprocess that opens the browser window, preventing zombie processes #1488 +Dependencies: + - Add dependency `oslex` + # v1.5.3 (2026-05-26) Add meta-data for the GitHub MCP registry diff --git a/pyproject.toml b/pyproject.toml index 9a03e553..ca221c0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dependencies = [ "cryptography==46.0.7", "regex==2026.2.28", "pythonnet==3.1.0 ; sys_platform == 'win32'", + "oslex==2.0.0", ] diff --git a/src/solidlsp/util/subprocess_util.py b/src/solidlsp/util/subprocess_util.py index 9d13773a..579b856e 100644 --- a/src/solidlsp/util/subprocess_util.py +++ b/src/solidlsp/util/subprocess_util.py @@ -1,8 +1,8 @@ import logging import platform -import shlex import subprocess +import oslex import psutil log = logging.getLogger(__name__) @@ -19,17 +19,16 @@ def subprocess_kwargs() -> dict: return kwargs -def convert_shell_cmd(cmd: str | list[str]) -> str | list[str]: +def convert_shell_cmd(cmd: str | list[str]) -> str: """ - Converts a command (specified as a list or string) to a format supported by subprocess calls with shell=True on the current platform. - List format must be converted to string format on POSIX systems, quoting arguments appropriately, - while it can be used as-is on Windows. + Converts a command (specified as a list or string) to a format supported by subprocess calls with shell=True on the current platform, + applying necessary escaping and quoting if the command is specified as a list of arguments. :param cmd: the command to convert, specified as a list of arguments :return: a suitable representation of the command for subprocess calls on the current platform """ - if isinstance(cmd, list) and platform.system() != "Windows": - return " ".join(shlex.quote(arg) for arg in cmd) + if isinstance(cmd, list): + return oslex.join(cmd) else: return cmd diff --git a/uv.lock b/uv.lock index 0f97a31d..bcaa24da 100644 --- a/uv.lock +++ b/uv.lock @@ -1466,6 +1466,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, ] +[[package]] +name = "mslex" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/97/7022667073c99a0fe028f2e34b9bf76b49a611afd21b02527fbfd92d4cd5/mslex-1.3.0.tar.gz", hash = "sha256:641c887d1d3db610eee2af37a8e5abda3f70b3006cdfd2d0d29dc0d1ae28a85d", size = 11583, upload-time = "2024-10-16T13:16:18.523Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/f2/66bd65ca0139675a0d7b18f0bada6e12b51a984e41a76dbe44761bf1b3ee/mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4", size = 7820, upload-time = "2024-10-16T13:16:17.566Z" }, +] + [[package]] name = "mypy" version = "1.17.0" @@ -1594,6 +1603,18 @@ 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 = "oslex" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mslex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/19/b74ea9590378a35014acf72f221e84c5980aa7531d1852ef961764e7d3a6/oslex-2.0.0.tar.gz", hash = "sha256:30d9f4a7201bdce3ab7d9cfc0f9ee9e18c423b2b1d1668141b0dd3594b368ffe", size = 23942, upload-time = "2026-05-10T21:22:36.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/d1/3c0bad919598c4216944934db565ed9834ad5b792e1b5e67e911bb2976d2/oslex-2.0.0-py3-none-any.whl", hash = "sha256:f1c2944072aafcb06b6ee8a6c018d9ebb8a7f43d7cf0b6630b4f5d7be45f6ae4", size = 5905, upload-time = "2026-05-10T21:22:37.514Z" }, +] + [[package]] name = "overrides" version = "7.7.0" @@ -2827,6 +2848,7 @@ dependencies = [ { name = "joblib" }, { name = "lsprotocol" }, { name = "mcp" }, + { name = "oslex" }, { name = "overrides" }, { name = "pathspec" }, { name = "psutil" }, @@ -2900,6 +2922,7 @@ requires-dist = [ { name = "lsprotocol", specifier = "==2025.0.0" }, { name = "mcp", specifier = "==1.27.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" }, + { name = "oslex", specifier = "==2.0.0" }, { name = "overrides", specifier = "==7.7.0" }, { name = "pathspec", specifier = "==0.12.1" }, { name = "poethepoet", marker = "extra == 'dev'", specifier = "==0.36.0" }, From ac44ed815e6bfe273381edabb7b06f7e6bf89666 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 12 Jun 2026 11:31:51 +0200 Subject: [PATCH 012/170] Support aliases of tool parameters, adding class SerenaFastMCPTool --- CHANGELOG.md | 1 + src/serena/mcp.py | 165 ++++++++++++++++------------ src/serena/tools/jetbrains_tools.py | 4 + src/serena/tools/symbol_tools.py | 4 + src/serena/tools/tools_base.py | 8 ++ 5 files changed, 113 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba7a287..8bc388a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: JetBrains mode prompt was not provided to agents; The mode is now treated as a (background) base mode in `ActiveModes` which reduces the surface for issues pertaining to custom handling of modes. - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) + - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 7520cacb..653f064c 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -11,8 +11,10 @@ from typing import Any, Literal, cast import docstring_parser from mcp.server.fastmcp import server -from mcp.server.fastmcp.server import FastMCP, Settings -from mcp.server.fastmcp.tools.base import Tool as MCPTool +from mcp.server.fastmcp.server import Context, FastMCP, Settings +from mcp.server.fastmcp.tools.base import Tool as FastMCPTool +from mcp.server.session import ServerSessionT +from mcp.shared.context import LifespanContextT, RequestT from mcp.types import ToolAnnotations from pydantic_settings import SettingsConfigDict from sensai.util import logging @@ -47,6 +49,94 @@ class SerenaMCPRequestContext: agent: SerenaAgent +class SerenaFastMCPTool(FastMCPTool): + def __init__(self, tool: Tool, openai_tool_compatible: bool): + """ + :param tool: the Serena tool + :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools + (doesn't accept integer, needs number instead, etc.). This allows using Serena MCP within Codex. + """ + func_name = tool.get_name() + func_doc = tool.get_apply_docstring() or "" + func_arg_metadata = tool.get_apply_fn_metadata() + is_async = False + parameters = func_arg_metadata.arg_model.model_json_schema() + if openai_tool_compatible: + parameters = SerenaMCPFactory._sanitize_for_openai_tools(parameters) + + docstring = docstring_parser.parse(func_doc) + + # Mount the tool description as a combination of the docstring description and + # the return value description, if it exists. + overridden_description = tool.agent.get_context().tool_description_overrides.get(func_name, None) + + if overridden_description is not None: + func_doc = overridden_description + elif docstring.description: + func_doc = docstring.description + else: + func_doc = "" + func_doc = func_doc.strip().strip(".") + if func_doc: + func_doc += "." + if docstring.returns and (docstring_returns_descr := docstring.returns.description): + # Only add a space before "Returns" if func_doc is not empty + prefix = " " if func_doc else "" + func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." + + # Parse the parameter descriptions from the docstring and add pass its description + # to the parameter schema. + docstring_params = {param.arg_name: param for param in docstring.params} + parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] + for parameter, properties in parameters_properties.items(): + if (param_doc := docstring_params.get(parameter)) and param_doc.description: + param_desc = f"{param_doc.description.strip().strip('.') + '.'}" + properties["description"] = param_desc[0].upper() + param_desc[1:] + + def execute_fn(**kwargs) -> str: # type: ignore + return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) + + # Generate human-readable title from snake_case tool name + tool_title = " ".join(word.capitalize() for word in func_name.split("_")) + + # Create annotations with appropriate hints based on tool capabilities + can_edit = tool.can_edit() + annotations = ToolAnnotations( + title=tool_title, + readOnlyHint=not can_edit, + destructiveHint=can_edit, + ) + + super().__init__( + fn=execute_fn, + name=func_name, + description=func_doc, + parameters=parameters, + fn_metadata=func_arg_metadata, + is_async=is_async, + # keep the value in sync with the kwarg name in Tool.apply_ex. The mcp sdk uses reflection to infer this + # when the tool is constructed via from_function (which is a bit crazy IMO, but well...) + context_kwarg="mcp_ctx", + annotations=annotations, + title=tool_title, + ) + + self._param_aliases = tool.get_param_aliases() + + async def run( + self, + arguments: dict[str, Any], + context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None, + convert_result: bool = False, + ) -> Any: + # apply parameter aliases + for param_alias, param_name in self._param_aliases.items(): + if param_alias in arguments and param_name not in arguments: + arguments[param_name] = arguments.pop(param_alias) + + return await super().run(arguments, context, convert_result) + + class SerenaMCPFactory: """ Factory for the creation of the Serena MCP server with an associated SerenaAgent. @@ -181,78 +271,15 @@ class SerenaMCPFactory: return walk(s) @staticmethod - def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True) -> MCPTool: + def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True) -> SerenaFastMCPTool: """ - Create an MCP tool from a Serena Tool instance. + Creates an MCP tool from a Serena Tool instance. - :param tool: The Serena Tool instance to convert. + :param tool: the Serena Tool instance to convert. :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools (doesn't accept integer, needs number instead, etc.). This allows using Serena MCP within codex. """ - func_name = tool.get_name() - func_doc = tool.get_apply_docstring() or "" - func_arg_metadata = tool.get_apply_fn_metadata() - is_async = False - parameters = func_arg_metadata.arg_model.model_json_schema() - if openai_tool_compatible: - parameters = SerenaMCPFactory._sanitize_for_openai_tools(parameters) - - docstring = docstring_parser.parse(func_doc) - - # Mount the tool description as a combination of the docstring description and - # the return value description, if it exists. - overridden_description = tool.agent.get_context().tool_description_overrides.get(func_name, None) - - if overridden_description is not None: - func_doc = overridden_description - elif docstring.description: - func_doc = docstring.description - else: - func_doc = "" - func_doc = func_doc.strip().strip(".") - if func_doc: - func_doc += "." - if docstring.returns and (docstring_returns_descr := docstring.returns.description): - # Only add a space before "Returns" if func_doc is not empty - prefix = " " if func_doc else "" - func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}." - - # Parse the parameter descriptions from the docstring and add pass its description - # to the parameter schema. - docstring_params = {param.arg_name: param for param in docstring.params} - parameters_properties: dict[str, dict[str, Any]] = parameters["properties"] - for parameter, properties in parameters_properties.items(): - if (param_doc := docstring_params.get(parameter)) and param_doc.description: - param_desc = f"{param_doc.description.strip().strip('.') + '.'}" - properties["description"] = param_desc[0].upper() + param_desc[1:] - - def execute_fn(**kwargs) -> str: # type: ignore - return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) - - # Generate human-readable title from snake_case tool name - tool_title = " ".join(word.capitalize() for word in func_name.split("_")) - - # Create annotations with appropriate hints based on tool capabilities - can_edit = tool.can_edit() - annotations = ToolAnnotations( - title=tool_title, - readOnlyHint=not can_edit, - destructiveHint=can_edit, - ) - - return MCPTool( - fn=execute_fn, - name=func_name, - description=func_doc, - parameters=parameters, - fn_metadata=func_arg_metadata, - is_async=is_async, - # keep the value in sync with the kwarg name in Tool.apply_ex. The mcp sdk uses reflection to infer this - # when the tool is constructed via from_function (which is a bit crazy IMO, but well...) - context_kwarg="mcp_ctx", - annotations=annotations, - title=tool_title, - ) + return SerenaFastMCPTool(tool, openai_tool_compatible) def _iter_tools(self) -> Iterator[Tool]: assert self.agent is not None diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 74a05359..3e71c45f 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -122,6 +122,10 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): result = self._to_json(grouped_symbols) return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_shortened_result]) + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + return {"name_path": "name_path_pattern"} + class JetBrainsMoveTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional, ToolMarkerBeta): """ diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index add9d345..7c11fce4 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -234,6 +234,10 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): result = self._to_json(grouped_symbol_dicts) return self._limit_length(result, max_answer_chars, shortened_result_factories=[create_short_result_relative_path_to_name_paths]) + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + return {"name_path": "name_path_pattern"} + class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): """ diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 89d064c3..44338dfd 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -306,6 +306,14 @@ class Tool(Component): def is_symbolic(self) -> bool: return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) + @classmethod + def get_param_aliases(cls) -> dict[str, str]: + """ + :return: a mapping of parameter aliases for the apply method, where the key is the alias and the value is the actual parameter name. + This can be used to define alternative parameter names for the same parameter. + """ + return {} + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore """ Applies the tool with logging and exception handling, using the given keyword arguments From 9abfc3d1be673b07bf366e76effcfa98d86ce6db Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 12 Jun 2026 19:40:12 +0200 Subject: [PATCH 013/170] Add a tool name replacement mapping for the generation of prompts --- CHANGELOG.md | 5 +-- pyproject.toml | 1 + src/serena/agent.py | 32 ++++++++++++++++++- src/serena/config/serena_config.py | 23 +++++++++++++ .../generated/generated_prompt_factory.py | 1 + .../config/internal_modes/jetbrains.yml | 8 +---- src/serena/resources/config/modes/editing.yml | 4 +-- .../config/prompt_templates/system_prompt.yml | 10 +++--- 8 files changed, 67 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba7a287..b7cb5bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,9 @@ Status of the `main` branch. Changes prior to the next official version change w incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); file access is now guarded with path detection (file ending or path separator must be present) - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration - - Fix: JetBrains mode prompt was not provided to agents; The mode is now treated as a (background) base mode - in `ActiveModes` which reduces the surface for issues pertaining to custom handling of modes. + - Adjust prompt generation mechanism to use newly introduced tool name mapping `tool_names`, allowing + prompts to directly use tool names that match the active language backend (and removing the need + for additional prompts that explain tool name differences) - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) * Language Servers: diff --git a/pyproject.toml b/pyproject.toml index ca221c0a..f2c8059f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -284,6 +284,7 @@ ignore = [ "UP042", # wants str,Enum -> StrEnum (breaking change) "PLW0108", # unnecessary lambda (style preference) "PLC0207", # split vs rsplit optimization (style preference) + "RSE102", # wants to force use of raise with class instead of instance (style preference) ] unfixable = ["F841", "F601", "F602", "B018"] extend-fixable = ["F401", "B905", "W291"] diff --git a/src/serena/agent.py b/src/serena/agent.py index 5a151377..1160b737 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -647,6 +647,9 @@ class SerenaAgent: else: log.info(f"Using language backend from global configuration: {self._language_backend.name}") + # create the tool names mapping for prompts + self._prompt_tool_names_mapping = self._create_prompt_tool_names_mapping(self._language_backend) + # create executor for starting the language server and running tools in another thread # This executor is used to achieve linear task execution self._task_executor = TaskExecutor("SerenaAgentTaskExecutor", self._task_completion_callback) @@ -927,9 +930,35 @@ class SerenaAgent: """ return self._active_modes + @staticmethod + def _create_prompt_tool_names_mapping(language_backend: LanguageBackend) -> dict[str, str]: + """ + Creates a mapping from tool names to new tool names, which take into consideration + + * legacy tool names, where the name was changed and + * LSP tools which are functionally replaced by other tools due to the active language backend + (e.g. "find_symbol" being replaced by "jet_brains_find_symbol" in JetBrains mode). + + The mapping is intended to be used for the generation of prompts, such that prompts can + refer to tool names as `{{ tool_names["find_symbol"] }}`, and the mapping will ensure that + the correct tool name is used in the prompt based on the active language backend. + + :return: the mapping from tool names to new tool names + """ + result = dict(ToolSet.LEGACY_TOOL_NAME_MAPPING) + class_replacements = language_backend.get_lsp_tool_class_replacements() + for tool_class in ToolRegistry().get_all_tool_classes(): + new_tool_class: type[Tool] = class_replacements.get(tool_class, tool_class) + result[tool_class.get_name_from_cls()] = new_tool_class.get_name_from_cls() + return result + def _format_prompt(self, prompt_template: str) -> str: template = JinjaTemplate(prompt_template) - return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names) + return template.render( + available_tools=self._exposed_tools.tool_names, + available_markers=self._exposed_tools.tool_marker_names, + tool_names=self._prompt_tool_names_mapping, + ) def create_connection_prompt(self) -> str: """ @@ -969,6 +998,7 @@ class SerenaAgent: available_tools=available_tools.tool_names, available_markers=available_markers, global_memories_list=global_memories_str, + tool_names=self._prompt_tool_names_mapping, ) # provide the project activation message if it hasn't yet been provided diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 15499fe3..8f2c3522 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -42,6 +42,7 @@ from ..util.dataclass import get_dataclass_default if TYPE_CHECKING: from ..project import Project + from ..tools.tools_base import Tool log = logging.getLogger(__name__) T = TypeVar("T") @@ -217,6 +218,28 @@ class LanguageBackend(Enum): def is_jetbrains(self) -> bool: return self == LanguageBackend.JETBRAINS + def get_lsp_tool_class_replacements(self) -> "dict[type[Tool], type[Tool]]": + """ + :return: mapping from LSP tool classes to replacement tool classes (functional replacements) + """ + match self: + case LanguageBackend.LSP: + return {} + case LanguageBackend.JETBRAINS: + from ..tools import jetbrains_tools, symbol_tools + + return { + symbol_tools.FindSymbolTool: jetbrains_tools.JetBrainsFindSymbolTool, + symbol_tools.GetSymbolsOverviewTool: jetbrains_tools.JetBrainsGetSymbolsOverviewTool, + symbol_tools.FindReferencingSymbolsTool: jetbrains_tools.JetBrainsFindReferencingSymbolsTool, + symbol_tools.FindImplementationsTool: jetbrains_tools.JetBrainsFindImplementationsTool, + symbol_tools.FindDeclarationTool: jetbrains_tools.JetBrainsFindDeclarationTool, + symbol_tools.RenameSymbolTool: jetbrains_tools.JetBrainsRenameTool, + symbol_tools.SafeDeleteSymbol: jetbrains_tools.JetBrainsSafeDeleteTool, + } + case _: + raise NotImplementedError() + class LineEnding(Enum): """Line ending convention for file writes.""" diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 2a43e586..7b0408e6 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -42,6 +42,7 @@ class PromptFactory(PromptFactoryBase): context_system_prompt: Any, global_memories_list: Any, mode_system_prompts: Any, + tool_names: Any, ) -> str: return self._render_prompt("system_prompt", locals()) diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index e3776dd9..e4eb218e 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -1,11 +1,5 @@ description: JetBrains tools replace language server-based tools -prompt: | - You have access to the very powerful JetBrains tools for symbolic operations. - These partly replace regular tools you were informed about: - * `jet_brains_find_symbol` replaces `find_symbol` - * `jet_brains_find_referencing_symbols` replaces `find_referencing_symbols` - * `jet_brains_get_symbols_overview` replaces `get_symbols_overview` - * `jet_brains_rename` replaces `rename_symbol` +prompt: excluded_tools: - find_symbol - find_referencing_symbols diff --git a/src/serena/resources/config/modes/editing.yml b/src/serena/resources/config/modes/editing.yml index 57d800b5..3c5c038c 100644 --- a/src/serena/resources/config/modes/editing.yml +++ b/src/serena/resources/config/modes/editing.yml @@ -21,9 +21,9 @@ prompt: | If you need to replace the definition of a symbol, use the `replace_symbol_body` tool. If you want to add some new code at the end of the file, use the `insert_after_symbol` tool with the last top-level symbol in the file. Similarly, you can use `insert_before_symbol` with the first top-level symbol in the file to insert code at the beginning of a file. - You can understand relationships between symbols by using the `find_referencing_symbols` tool. If not explicitly requested otherwise by the user, + You can understand relationships between symbols by using the ``{{ tool_names['find_referencing_symbols'] }}`` tool. If not explicitly requested otherwise by the user, you make sure that when you edit a symbol, the change is either backward-compatible or you find and update all references as needed. - The `find_referencing_symbols` tool will give you code snippets around the references as well as symbolic information. + The `{{ tool_names['find_referencing_symbols'] }}` tool will give you code snippets around the references as well as symbolic information. You can assume that all symbol editing tools are reliable, so you never need to verify the results if the tools return without error. {% if 'replace_content' in available_tools %} diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index 722d3a9d..c82b2bb5 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -26,14 +26,14 @@ prompts: {% endif %} {% if 'ToolMarkerSymbolicRead' in available_markers %} - Symbols are identified by their `name_path` and `relative_path` (see the description of the `find_symbol` tool). - You can get information about the symbols in a file by using the `get_symbols_overview` tool or use the `find_symbol` to search. + Symbols are identified by their `name_path` and `relative_path` (see the description of the `{{ tool_names['find_symbol'] }}` tool). + You can get information about the symbols in a file by using the `{{ tool_names['get_symbols_overview'] }}` tool or use the `{{ tool_names['find_symbol'] }}` to search. You only read the bodies of symbols when you need to (e.g. if you want to fully understand or edit it). For example, if you are working with Python code and already know that you need to read the body of the constructor of the class Foo, you can directly - use `find_symbol` with name path pattern `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit, - you can use `find_symbol` with name path pattern `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding + use `{{ tool_names['find_symbol'] }}` with name path pattern `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit, + you can use `{{ tool_names['find_symbol'] }}` with name path pattern `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding to read the desired methods with `include_body=True`. - You can understand relationships between symbols by using the `find_referencing_symbols` tool. + You can understand relationships between symbols by using the `{{ tool_names['find_referencing_symbols'] }}` tool. {% endif %} {% if 'read_memory' in available_tools -%} From 6e0fbe39ca37cebb42941e2692653cd267437471 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:06:46 +0000 Subject: [PATCH 014/170] Bump tornado from 6.5.5 to 6.5.6 in the uv group across 1 directory Bumps the uv group with 1 update in the / directory: [tornado](https://github.com/tornadoweb/tornado). Updates `tornado` from 6.5.5 to 6.5.6 - [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst) - [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.5...v6.5.6) --- updated-dependencies: - dependency-name: tornado dependency-version: 6.5.6 dependency-type: direct:production dependency-group: uv ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e5f0896b..c6d47682 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,7 +92,7 @@ dev = [ "pytest-timeout==2.4.0", # Transitive dev deps pinned for security (dependabot alerts). # Exact pins because uvx installs from git, ignoring the lock file. - "tornado==6.5.5", + "tornado==6.5.6", "wheel==0.46.3", "pyasn1==0.6.3", "PyJWT==2.12.0", diff --git a/uv.lock b/uv.lock index ddcdd53d..1dd9c4e4 100644 --- a/uv.lock +++ b/uv.lock @@ -2955,7 +2955,7 @@ requires-dist = [ { name = "syrupy", marker = "extra == 'dev'", specifier = "==4.9.1" }, { name = "tiktoken", specifier = "==0.12.0" }, { name = "toml-sort", marker = "extra == 'dev'", specifier = "==0.24.2" }, - { name = "tornado", marker = "extra == 'dev'", specifier = "==6.5.5" }, + { name = "tornado", marker = "extra == 'dev'", specifier = "==6.5.6" }, { name = "tqdm", specifier = "==4.67.1" }, { name = "types-pyyaml", specifier = "==6.0.12.20250516" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.20250516" }, @@ -3547,19 +3547,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.5" +version = "6.5.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, - { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, - { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, - { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, - { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" }, + { url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" }, + { url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" }, ] [[package]] From 5006ef79969a3d10b2d1b3e057522fafacdbd2ed Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sat, 13 Jun 2026 04:44:11 +0200 Subject: [PATCH 015/170] fix: invalidate JDTLS workspace cache for Java import setting changes Fixes #1541 Co-Authored-By: Claude --- .../language_servers/eclipse_jdtls.py | 28 +++++++++++++++---- .../java/test_jdtls_path_resolution.py | 27 +++++++++++++++++- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index cb84dc5c..2479f13e 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -4,6 +4,7 @@ Provides Java specific instantiation of the LanguageServer class. Contains vario import dataclasses import hashlib +import json import logging import os import pathlib @@ -776,20 +777,37 @@ class EclipseJDTLS(SolidLanguageServer): vscode-java VSIX bump or upstream install change) lands in a separate ws_dir and avoids stale OSGi configs from the previous version blocking startup. + Workspace-affecting settings are also mixed in so changing Maven/Gradle/JDK + inputs lands in a fresh workspace instead of silently reusing stale import + state from a previous configuration. + Exception: legacy default-mode users on INITIAL_VSCODE_JAVA_VERSION keep the - original ``md5(repository_root_path)`` format. These are users who installed - under the legacy unversioned ``vscode-java/`` directory (see the version-pinning - convention at the top of this module) — preserving their hash means existing - JDTLS workspaces and project caches are reused without a one-time reindex. + original ``md5(repository_root_path)`` format *until* one of the tracked + workspace-affecting settings is set. This preserves existing caches for the + old default setup while still fixing stale-workspace bugs once users opt into + custom Java import settings. """ + workspace_setting_keys = ( + "maven_user_settings", + "gradle_user_home", + "gradle_wrapper_enabled", + "gradle_java_home", + "java_home", + "use_system_java_home", + "maven_offline", + ) + workspace_settings = {key: custom_settings.settings[key] for key in workspace_setting_keys if key in custom_settings.settings} + workspace_settings_json = json.dumps(workspace_settings, sort_keys=True, separators=(",", ":")) + is_legacy_initial = ( not custom_settings.get("jdtls_path") and custom_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) == INITIAL_VSCODE_JAVA_VERSION + and not workspace_settings ) if is_legacy_initial: ws_hash_input = repository_root_path.encode() else: - ws_hash_input = (repository_root_path + "|" + jdtls_launcher_jar_path).encode() + ws_hash_input = (repository_root_path + "|" + jdtls_launcher_jar_path + "|" + workspace_settings_json).encode() return hashlib.md5(ws_hash_input).hexdigest() def create_launch_command(self) -> list[str]: diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 6ce204d1..2154eb9b 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -393,9 +393,12 @@ class TestComputeWorkspaceHash: (default vscode-java VSIX bump or upstream install change) lands in a separate ws_dir and avoids stale OSGi configs from the previous version blocking startup. + Workspace-affecting Java settings are also mixed into the hash so stale Maven / + Gradle import caches are not silently reused after config changes. + Backwards-compatibility carve-out: legacy default-mode users on INITIAL_VSCODE_JAVA_VERSION keep the original ``md5(repository_root_path)`` format, - so existing JDTLS workspaces and project caches are reused without a one-time reindex. + as long as they have not opted into tracked workspace-affecting settings. """ REPO = "/home/me/projects/widgets" @@ -451,3 +454,25 @@ class TestComputeWorkspaceHash: h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/a/repo", self.DEFAULT_LAUNCHER, empty_settings) h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash("/b/repo", self.DEFAULT_LAUNCHER, empty_settings) assert h1 != h2 + + def test_workspace_affecting_settings_change_hash(self) -> None: + base_settings = SolidLSPSettings.CustomLSSettings({"maven_user_settings": "/tmp/maven-a.xml"}) + changed_settings = SolidLSPSettings.CustomLSSettings({"maven_user_settings": "/tmp/maven-b.xml"}) + + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, base_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, changed_settings) + + assert h1 != h2 + + def test_legacy_initial_mode_still_invalidates_when_workspace_settings_change(self) -> None: + settings = SolidLSPSettings.CustomLSSettings( + { + "vscode_java_version": self._initial_settings().get("vscode_java_version"), + "gradle_user_home": "/tmp/gradle-home", + } + ) + + legacy_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, self._initial_settings()) + configured_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings) + + assert configured_hash != legacy_hash From 652a739826b9f54a0161fd2cf385c3259db24f32 Mon Sep 17 00:00:00 2001 From: Tirth Patel Date: Mon, 15 Jun 2026 15:39:36 +0530 Subject: [PATCH 016/170] Merge pull request #1421 from tirthpatel90/feature/parallel-ci-workflows ci: implement parallel matrix architecture for segmented testing, including a separate docker image for CI that contains all dependencies --- .github/workflows/build-maximal-image.yml | 43 ++++++ .github/workflows/test-parallel.yml | 106 ++++++++++++++ Dockerfile.maximal | 164 ++++++++++++++++++++++ 3 files changed, 313 insertions(+) create mode 100644 .github/workflows/build-maximal-image.yml create mode 100644 .github/workflows/test-parallel.yml create mode 100644 Dockerfile.maximal diff --git a/.github/workflows/build-maximal-image.yml b/.github/workflows/build-maximal-image.yml new file mode 100644 index 00000000..b4b4ef8d --- /dev/null +++ b/.github/workflows/build-maximal-image.yml @@ -0,0 +1,43 @@ +name: Build and Publish Maximal Image + +on: + push: + branches: + - main + paths: + - 'Dockerfile.maximal' + - '.github/workflows/build-maximal-image.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }}-maximal + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.maximal + push: true + tags: ghcr.io/${{ github.repository }}-maximal:latest + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/test-parallel.yml b/.github/workflows/test-parallel.yml new file mode 100644 index 00000000..f5bb1a87 --- /dev/null +++ b/.github/workflows/test-parallel.yml @@ -0,0 +1,106 @@ +name: Parallel Matrix CI + +on: + push: + branches: + - feature/parallel-ci-workflows + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + container: + image: ghcr.io/${{ github.repository }}-maximal:latest + + strategy: + fail-fast: false + matrix: + include: + - batch_name: "Heavy Toolchains (C++, Rust, Java)" + marker_expr: 'cpp or rust or java' + + - batch_name: "Medium Toolchains (Go)" + marker_expr: 'go' + + - batch_name: "Catch-All (Core Framework & Remaining Languages)" + marker_expr: 'not cpp and not rust and not java and not go and not elm and not r and not rego and not scala' + + name: ${{ matrix.batch_name }} + + env: + UV_LINK_MODE: copy + + steps: + - uses: actions/checkout@v4 + + - name: Cache language servers + uses: actions/cache@v4 + with: + path: ~/.serena/language_servers + key: ls-cache-${{ runner.os }}-${{ github.sha }} + restore-keys: | + ls-cache-${{ runner.os }}- + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + + - name: Create venv + install deps + shell: bash + run: | + set -euxo pipefail + uv venv .venv + .venv/bin/python -m ensurepip --upgrade || true + .venv/bin/python -m pip install -U pip setuptools wheel + uv pip install -e ".[dev]" + .venv/bin/python -m pytest --version + + - name: Dotnet diagnostics (non-blocking) + shell: bash + run: | + set -euxo pipefail + dotnet --list-runtimes || true + dotnet --info || true + + - name: Compute marker expression (final architecture quarantine) + id: markers + shell: bash + run: | + set -euo pipefail + MARKERS='${{ matrix.marker_expr }}' + + if [[ '${{ matrix.batch_name }}' == 'Catch-All (Core Framework & Remaining Languages)' ]]; then + # Strict Quarantining: Explicitly disabling flaky auto-install dependencies & missing heavy toolchains + MARKERS="$MARKERS and not ruby and not php and not clojure and not kotlin and not msl and not nix and not zig and not swift and not perl and not fortran and not haskell and not elixir and not fsharp and not groovy and not matlab and not yaml and not json and not ansible and not julia and not lean4 and not ocaml and not pascal and not powershell and not svelte and not csharp" + fi + + echo "MARKERS=$MARKERS" + echo "markers=$MARKERS" >> "$GITHUB_OUTPUT" + + - name: Run pytest + shell: bash + run: | + set -euxo pipefail + echo "Batch: ${{ matrix.batch_name }}" + echo "Running: pytest -m '${{ steps.markers.outputs.markers }}'" + + # Catch-All only: deselect known flaky tests and environment-dependent GUI tests + if [[ '${{ matrix.batch_name }}' == 'Catch-All (Core Framework & Remaining Languages)' ]]; then + .venv/bin/python -m pytest \ + -m "${{ steps.markers.outputs.markers }}" \ + -k "not test_find_symbol_references_stable and not test_show_fatal_exception_safe" \ + --maxfail=1 -q + else + .venv/bin/python -m pytest \ + -m "${{ steps.markers.outputs.markers }}" \ + --maxfail=1 -q + fi \ No newline at end of file diff --git a/Dockerfile.maximal b/Dockerfile.maximal new file mode 100644 index 00000000..010ec2be --- /dev/null +++ b/Dockerfile.maximal @@ -0,0 +1,164 @@ +# Base stage with common dependencies +FROM python:3.11-slim AS base +SHELL ["/bin/bash", "-c"] + +# Set environment variables to make Python print directly to the terminal and avoid .pyc files. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +# Install system dependencies required for package manager and build tools. +# Notes: +# - python3-venv: makes venv/ensurepip reliable +# - locales + libicu: required for dotnet to run on slim images +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + build-essential \ + git \ + ssh \ + sudo \ + wget \ + zip \ + unzip \ + sed \ + python3-venv \ + locales \ + libicu-dev \ + && rm -rf /var/lib/apt/lists/* + +# Configure UTF-8 locale (helps dotnet and some language servers) +RUN sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen +ENV LANG=en_US.UTF-8 +ENV LC_ALL=en_US.UTF-8 + +# Install pipx +RUN python3 -m pip install --no-cache-dir pipx \ + && pipx ensurepath + +# Install nodejs via NVM +ENV NVM_VERSION=0.40.3 +ENV NODE_VERSION=22.18.0 +RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash +ENV NVM_DIR=/root/.nvm +RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION} \ + && . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION} \ + && . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION} +ENV PATH="${NVM_DIR}/versions/node/v${NODE_VERSION}/bin/:${PATH}" + +# Add local bin to the path +ENV PATH="${PATH}:/root/.local/bin" + +# Install uv (available as `uv` binary) +RUN curl -LsSf https://astral.sh/uv/install.sh | sh + +# Install Rust and rustup for rust-analyzer support (minimal profile) +ENV RUSTUP_HOME=/usr/local/rustup +ENV CARGO_HOME=/usr/local/cargo +ENV PATH="${CARGO_HOME}/bin:${PATH}" +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ + --default-toolchain stable \ + --profile minimal \ + && rustup component add rust-analyzer + +# ============================================================================== +# MAXIMAL ADDITIONS (For Catch-All CI Testing) +# ============================================================================== + +# 1. Install Ruby, Java (JDK & JRE), Scala, Haxe, Nix, and Clangd (C++) via apt +RUN apt-get update && apt-get install -y --no-install-recommends \ + ruby-full \ + default-jre \ + default-jdk \ + scala \ + haxe \ + nix-bin \ + clangd \ + && rm -rf /var/lib/apt/lists/* + +# Install Ruby language server (needed for ruby tests) +RUN gem install ruby-lsp + +# 1.25 Install Lean 4 via elan (required for lean4 tests) +ENV ELAN_HOME=/root/.elan +ENV PATH="${ELAN_HOME}/bin:${PATH}" +RUN curl -sSf https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh | sh -s -- -y \ + && elan default stable \ + && lean --version + +# 1.5 Install Julia officially via pre-compiled binaries +RUN curl -L -o /tmp/julia.tar.gz https://julialang-s3.julialang.org/bin/linux/x64/1.10/julia-1.10.4-linux-x86_64.tar.gz \ + && mkdir -p /opt/julia \ + && tar zxf /tmp/julia.tar.gz -C /opt/julia --strip-components=1 \ + && ln -s /opt/julia/bin/julia /usr/local/bin/julia \ + && rm /tmp/julia.tar.gz + +# 2. Install PowerShell (pwsh) +RUN curl -L -o /tmp/powershell.tar.gz https://github.com/PowerShell/PowerShell/releases/download/v7.4.2/powershell-7.4.2-linux-x64.tar.gz \ + && mkdir -p /opt/microsoft/powershell/7 \ + && tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \ + && chmod +x /opt/microsoft/powershell/7/pwsh \ + && ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \ + && rm /tmp/powershell.tar.gz + +# 3. Install .NET (stable, for Roslyn language server) +ENV DOTNET_ROOT=/root/.dotnet +ENV PATH="${DOTNET_ROOT}:${PATH}" +RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ + && chmod +x /tmp/dotnet-install.sh \ + && /tmp/dotnet-install.sh --channel 10.0 --install-dir "${DOTNET_ROOT}" \ + && /tmp/dotnet-install.sh --channel 10.0 --runtime dotnet --install-dir "${DOTNET_ROOT}" \ + && ln -sf "${DOTNET_ROOT}/dotnet" /usr/bin/dotnet \ + && rm -f /tmp/dotnet-install.sh + +# 4. Install Go and gopls globally in system paths +ENV GO_VERSION=1.22.3 +RUN curl -L -o /tmp/go.tar.gz https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz \ + && tar -C /usr/local -xzf /tmp/go.tar.gz \ + && rm /tmp/go.tar.gz +ENV PATH="/usr/local/go/bin:${PATH}" +RUN go install golang.org/x/tools/gopls@latest \ + && mv /root/go/bin/gopls /usr/local/bin/gopls + +# 5. Install Terraform +ENV TERRAFORM_VERSION=1.8.4 +RUN curl -L -o /tmp/terraform.zip https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ + && unzip /tmp/terraform.zip -d /usr/local/bin/ \ + && rm /tmp/terraform.zip + +# 6. Install JS Ecosystem Language Servers (Typescript, Vue, Bash, HTML/CSS) +RUN npm install -g \ + typescript \ + typescript-language-server \ + @vue/language-server \ + bash-language-server \ + vscode-langservers-extracted + +# 7. Install Java Language Server (jdtls) snapshot with global system binary wrapper +RUN wget https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz -O /tmp/jdtls.tar.gz \ + && mkdir -p /opt/jdtls \ + && tar -xzf /tmp/jdtls.tar.gz -C /opt/jdtls \ + && rm /tmp/jdtls.tar.gz \ + && printf '#!/bin/bash\njava -Declipse.application=org.eclipse.jdt.ls.core.id1 -Dosgi.bundles.defaultStartLevel=4 -Declipse.product=org.eclipse.jdt.ls.core.product -noverify -Xmx1G -jar /opt/jdtls/plugins/org.eclipse.equinox.launcher_*.jar -configuration /opt/jdtls/config_linux -data /tmp/workspace "$@"\n' > /usr/local/bin/jdtls \ + && chmod +x /usr/local/bin/jdtls + +# ============================================================================== +# END MAXIMAL ADDITIONS +# ============================================================================== + +# Set the working directory +WORKDIR /workspaces/serena + +# Copy all files for development +COPY . /workspaces/serena/ + +# Create Serena configuration +ENV SERENA_HOME=/workspaces/serena/config +RUN mkdir -p "$SERENA_HOME" \ + && cp src/serena/resources/serena_config.template.yml "$SERENA_HOME/serena_config.yml" \ + && sed -i 's/^gui_log_window: .*/gui_log_window: False/' "$SERENA_HOME/serena_config.yml" \ + && sed -i 's/^web_dashboard_listen_address: .*/web_dashboard_listen_address: 0.0.0.0/' "$SERENA_HOME/serena_config.yml" \ + && sed -i 's/^web_dashboard_open_on_launch: .*/web_dashboard_open_on_launch: False/' "$SERENA_HOME/serena_config.yml" + +CMD ["/bin/bash"] \ No newline at end of file From b25c21952a8b30a3f3e173048ebae0a5629d02b3 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Jun 2026 12:47:03 +0200 Subject: [PATCH 017/170] Make tool call errors surface explicitly as errors at the MCP level * Refactor Tool.apply_ex to either return a success response or raise ToolCallError (if catch_exceptions=False) * Adapt SerenaFastMCPTool to catch ToolCallError and reraise as fastmcp.exceptions.ToolError, triggering an error response in the SDK --- CHANGELOG.md | 1 + src/serena/mcp.py | 8 +++-- src/serena/tools/tools_base.py | 60 ++++++++++++++++++++++++---------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f200e4..088783a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Status of the `main` branch. Changes prior to the next official version change w for additional prompts that explain tool name differences) - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools + - Make tool call errors surface explicitly as errors at the MCP protocol level * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 653f064c..a6cf527c 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -11,6 +11,7 @@ from typing import Any, Literal, cast import docstring_parser from mcp.server.fastmcp import server +from mcp.server.fastmcp.exceptions import ToolError from mcp.server.fastmcp.server import Context, FastMCP, Settings from mcp.server.fastmcp.tools.base import Tool as FastMCPTool from mcp.server.session import ServerSessionT @@ -26,7 +27,7 @@ from serena.agent import ( from serena.config.context_mode import SerenaAgentContext from serena.config.serena_config import LanguageBackend, ModeSelectionDefinition from serena.constants import DEFAULT_CONTEXT, SERENA_LOG_FORMAT -from serena.tools import Tool +from serena.tools import Tool, ToolCallError from serena.util.exception import show_fatal_exception_safe from serena.util.logging import MemoryLogHandler @@ -94,7 +95,10 @@ class SerenaFastMCPTool(FastMCPTool): properties["description"] = param_desc[0].upper() + param_desc[1:] def execute_fn(**kwargs) -> str: # type: ignore - return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) + try: + return tool.apply_ex(log_call=True, catch_exceptions=False, **kwargs) + except ToolCallError as e: + raise ToolError(e.get_error_message()) from e # Generate human-readable title from snake_case tool name tool_title = " ".join(word.capitalize() for word in func_name.split("_")) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 44338dfd..c37daee4 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -126,6 +126,19 @@ class ApplyMethodProtocol(Protocol): pass +class ToolCallError(Exception): + """ + Represents an error raised during a tool call execution + """ + + def __init__(self, error_message: str): + super().__init__(error_message) + self._error_message = error_message + + def get_error_message(self) -> str: + return self._error_message + + class Tool(Component): # NOTE: each tool should implement the apply method, which is then used in # the central method of the Tool class `apply_ex`. @@ -316,8 +329,14 @@ class Tool(Component): def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore """ - Applies the tool with logging and exception handling, using the given keyword arguments + Applies the tool with logging and exception handling, using the given keyword arguments. + This method either returns a string result or raises a ToolCallError in case of an error during tool application + (but if `catch_exception is enabled, it will return the error message as a string instead of raising the exception). + + :param log_call: whether to log the tool call and its result + :param catch_exceptions: whether to catch exceptions and return their messages as strings, instead of raising a ToolCallError """ + # obtain session ID and client info session_id = "global" if mcp_ctx is not None: try: @@ -337,18 +356,18 @@ class Tool(Component): try: if not self.is_active(): - return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}" - except Exception as e: - return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}" + raise ToolCallError( + f"Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}" + ) + + if log_call: + self._log_tool_application(inspect.currentframe(), session_id) - if log_call: - self._log_tool_application(inspect.currentframe(), session_id) - try: # check whether the tool requires an active project and language server if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): if self.agent.get_active_project() is None: - return ( - "Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: " + raise ToolCallError( + "No active project. Ask the user to provide the project path or to select a project from this list of known projects: " + f"{self.agent.serena_config.project_names}" ) @@ -380,12 +399,12 @@ class Tool(Component): # record tool usage self.agent.record_tool_usage(apply_kwargs, result, self) + except ToolCallError: + raise except Exception as e: - if not catch_exceptions: - raise - msg = f"Error executing tool: {e.__class__.__name__} - {e}" - log.error(f"Error executing tool: {e}", exc_info=e) - result = msg + msg = f"{e.__class__.__name__}: {e}" + log.error(msg, exc_info=e) + raise ToolCallError(msg) if log_call: log.info(f"Result: {result}") @@ -400,13 +419,20 @@ class Tool(Component): return result # execute the tool in the agent's task executor, with timeout + tool_call_error: ToolCallError try: task_exec = self.agent.issue_task(task, name=self.__class__.__name__) return task_exec.result(timeout=self.agent.serena_config.tool_timeout) - except Exception as e: # typically TimeoutError (other exceptions caught in task) - msg = f"Error: {e.__class__.__name__} - {e}" + except ToolCallError as e: + tool_call_error = e + except Exception as e: # typically TimeoutError (other exceptions caught and forwarded as ToolCallError in the task) + msg = f"{e.__class__.__name__}: {e}" log.error(msg) - return msg + tool_call_error = ToolCallError(msg) + if catch_exceptions: + return tool_call_error.get_error_message() + else: + raise tool_call_error @staticmethod def _to_json(x: Any) -> str: From d6844aabe52e9adc935e2d05ad0f4342977c8660 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Jun 2026 12:50:14 +0200 Subject: [PATCH 018/170] MemoryManager.load_memory: Raise exception on error --- src/serena/memories/memory_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/memories/memory_manager.py b/src/serena/memories/memory_manager.py index f3d4a22f..76c24447 100644 --- a/src/serena/memories/memory_manager.py +++ b/src/serena/memories/memory_manager.py @@ -191,7 +191,7 @@ class MemoryManager: self._check_not_ignored(name) memory_file_path = self.get_memory_file_path(name) if not memory_file_path.exists(): - return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it." + raise FileNotFoundError(f"Memory named '{name}' not found") with open(memory_file_path, encoding=self._encoding) as f: return f.read() From f84452cdd3ae40e8f67b7c525208c8ab8f8d5558 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 16 Jun 2026 15:36:59 +0200 Subject: [PATCH 019/170] CI: removed ubuntu from os matrix (covered by parallel tests) Removed feature/parallel-ci-workflows as trigger for action --- .github/workflows/pytest.yml | 2 +- .github/workflows/test-parallel.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 58ba0032..efb77935 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [windows-latest, macos-latest] python-version: ["3.11"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/test-parallel.yml b/.github/workflows/test-parallel.yml index f5bb1a87..f9a289ab 100644 --- a/.github/workflows/test-parallel.yml +++ b/.github/workflows/test-parallel.yml @@ -3,7 +3,6 @@ name: Parallel Matrix CI on: push: branches: - - feature/parallel-ci-workflows - main pull_request: branches: From dd7eb6d72ae179aa940e50cd6276ec5646f306f8 Mon Sep 17 00:00:00 2001 From: Feng Lin <40869509+flin2@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:56:21 -0500 Subject: [PATCH 020/170] Prevent Julia LS setup from clobbering Serena's stdio MCP pipe (#1580) * Prevent Julia LS probe from clobbering Serena's stdio MCP pipe When Serena runs over the stdio MCP transport, stdin/stdout *are* the JSON-RPC channel. The Julia language-server setup spawns probe/install subprocesses with `capture_output=True`, which redirects the child's stdout/stderr but not its stdin, so the Julia child inherits Serena's stdin (the MCP pipe) and clobbers it. The stdio server then exits within milliseconds of `initialize`, and the client reports "tools fetch failed". - Pass `stdin=subprocess.DEVNULL` to both `subprocess.run` calls in `julia_server.py` (`_setup_runtime_dependency` probe and `_install_language_server` install). - Add a comment explaining the stdio-transport hazard. Fixes #1577 * Add changelog entry for the Julia stdio MCP fix Document the fix under the Unreleased "Language Servers" section. --- CHANGELOG.md | 4 ++++ src/solidlsp/language_servers/julia_server.py | 12 +++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 088783a6..74728305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ Status of the `main` branch. Changes prior to the next official version change w results in svelte-only mode (`languages: [svelte]`). They are now routed to the companion TS server, so symbols defined in plain `.ts`/`.js` files are again discoverable via `find_symbol`/ `get_symbols_overview`, and `find_referencing_symbols` no longer fails to locate `.ts`/`.js` symbols. #1552 + - `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed") + when `julia` is enabled. The runtime probe/install subprocesses now run with `stdin=subprocess.DEVNULL`, + so they can no longer inherit and clobber Serena's stdin, which is the JSON-RPC pipe under the stdio + transport. #1577 - Improve quoting of arguments in shell executions * JetBrains: diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index ff3acd66..71f978c9 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -78,10 +78,14 @@ class JuliaLanguageServer(SolidLanguageServer): f"Checked locations: {common_locations}" ) - # Check if LanguageServer.jl is installed + # Check if LanguageServer.jl is installed. + # stdin=DEVNULL: when Serena runs over the stdio MCP transport, the JSON-RPC + # channel *is* the process stdin/stdout. Without this, the Julia child inherits + # Serena's stdin (the MCP pipe) and clobbers it, killing the server right after + # initialize ("tools fetch failed"). See https://github.com/oraios/serena/issues/1577 check_cmd = [julia_path, "-e", "using LanguageServer"] try: - result = subprocess.run(check_cmd, check=False, capture_output=True, text=True, timeout=10) + result = subprocess.run(check_cmd, check=False, capture_output=True, text=True, timeout=10, stdin=subprocess.DEVNULL) if result.returncode != 0: # LanguageServer.jl not found, install it JuliaLanguageServer._install_language_server(julia_path) @@ -99,7 +103,9 @@ class JuliaLanguageServer(SolidLanguageServer): install_cmd = [julia_path, "-e", 'using Pkg; Pkg.add("LanguageServer")'] try: - result = subprocess.run(install_cmd, check=False, capture_output=True, text=True, timeout=300) # 5 minutes for installation + result = subprocess.run( + install_cmd, check=False, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL + ) # 5 minutes for installation if result.returncode == 0: log.info("LanguageServer.jl installed successfully!") From e1f015344f6dac78ab1dcab7d7c17b15da96083d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 19 Jun 2026 11:41:27 +0200 Subject: [PATCH 021/170] Add SerenaConfig.with_headless_mode_overrides and apply it in relevant CLI commands and tests, fixing the newly introduced issue where some CLI commands may open a JetBrains IDE if the respective config setting was set. Fixes #1578 --- src/serena/cli.py | 9 +-- src/serena/config/serena_config.py | 13 ++++ .../config/test_global_ignored_paths.py | 18 +++--- test/serena/config/test_serena_config.py | 64 +++++-------------- test/serena/test_serena_agent.py | 4 +- test/serena/test_tool_parameter_types.py | 2 +- 6 files changed, 43 insertions(+), 67 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 98b8ed66..b13d3860 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -429,8 +429,7 @@ class TopLevelCommands(AutoRegisteringGroup): modes_selection_def: ModeSelectionDefinition | None = None if modes: modes_selection_def = ModeSelectionDefinition(default_modes=modes) - serena_config = SerenaConfig.from_config_file() - serena_config.web_dashboard = False + serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides() print(serena_config.default_modes) print(serena_config.base_modes) @@ -928,10 +927,8 @@ class ProjectCommands(AutoRegisteringGroup): logging.configure(level=logging.INFO) project_path = os.path.abspath(project) - serena_config = SerenaConfig.from_config_file() + serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides() serena_config.language_backend = LanguageBackend.LSP - serena_config.gui_log_window = False - serena_config.web_dashboard = False proj = Project.load(project_path, serena_config=serena_config) # Create log file with timestamp @@ -1113,7 +1110,7 @@ class ToolCommands(AutoRegisteringGroup): agent = SerenaAgent( project=None, - serena_config=SerenaConfig(web_dashboard=False, log_level=logging.INFO), + serena_config=SerenaConfig(log_level=logging.INFO).with_headless_mode_overrides(), context=serena_context, ) tool = agent.get_tool_by_name(tool_name) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 8f2c3522..6a460357 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -1022,6 +1022,19 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): config._save() return config + def with_headless_mode_overrides(self) -> "SerenaConfig": + """ + Modifies this instance to apply overrides for headless mode, where any GUI/user interaction-based features are disabled. + This is intended to be applied for cases where a `SerenaConfig` instance is needed to instantiate a `SerenaAgent` instance + while the user is not expected to interact with the system (e.g. a CLI command or a test). + + :return: the instance with overrides applied for headless mode + """ + self.gui_log_window = False + self.web_dashboard = False + self.jetbrains_launch_command = None + return self + @cached_property def project_paths(self) -> list[str]: return sorted(str(project.project_root) for project in self.projects) diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py index da9fadb9..862ac2f2 100644 --- a/test/serena/config/test_global_ignored_paths.py +++ b/test/serena/config/test_global_ignored_paths.py @@ -20,7 +20,7 @@ def _create_test_project( ignored_paths=project_ignored_paths or [], ignore_all_files_in_gitignore=False, ) - serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths) + serena_config = SerenaConfig(ignored_paths=global_ignored_paths).with_headless_mode_overrides() return Project( project_root=str(project_root), project_config=config, @@ -149,7 +149,7 @@ class TestRegisteredProjectGlobalIgnoredPaths: ignored_paths=[], ignore_all_files_in_gitignore=False, ) - serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + serena_config = SerenaConfig(ignored_paths=["node_modules"]).with_headless_mode_overrides() registered = RegisteredProject( project_root=str(self.project_path), project_config=config, @@ -169,7 +169,7 @@ class TestRegisteredProjectGlobalIgnoredPaths: project_root=str(self.project_path), project_config=config, ) - serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=[]) + serena_config = SerenaConfig(ignored_paths=[]).with_headless_mode_overrides() project = registered.get_project_instance(serena_config=serena_config) assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) @@ -181,7 +181,7 @@ class TestRegisteredProjectGlobalIgnoredPaths: (serena_dir / "project.yml").write_text( 'project_name: "test_project"\nlanguages: ["python"]\nignored_paths: []\nignore_all_files_in_gitignore: false\n' ) - serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + serena_config = SerenaConfig(ignored_paths=["node_modules"]).with_headless_mode_overrides() registered = RegisteredProject.from_project_root( str(self.project_path), serena_config=serena_config, @@ -197,7 +197,7 @@ class TestRegisteredProjectGlobalIgnoredPaths: ignored_paths=[], ignore_all_files_in_gitignore=False, ) - serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + serena_config = SerenaConfig(ignored_paths=["node_modules"]).with_headless_mode_overrides() project = Project( project_root=str(self.project_path), project_config=config, @@ -237,7 +237,7 @@ class TestGlobalIgnoredPathsWithGitignore: ignored_paths=["build"], ignore_all_files_in_gitignore=True, ) - serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=["node_modules"]) + serena_config = SerenaConfig(ignored_paths=["node_modules"]).with_headless_mode_overrides() project = Project( project_root=str(self.project_path), project_config=config, @@ -258,14 +258,12 @@ class TestSerenaConfigIgnoredPaths: def test_serena_config_default_ignored_paths(self) -> None: """SerenaConfig defaults to empty ignored_paths.""" - config = SerenaConfig(gui_log_window=False, web_dashboard=False) + config = SerenaConfig().with_headless_mode_overrides() assert config.ignored_paths == [] def test_serena_config_with_ignored_paths(self) -> None: """SerenaConfig can be created with explicit ignored_paths.""" config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, ignored_paths=["node_modules", "*.log", "build"], - ) + ).with_headless_mode_overrides() assert config.ignored_paths == ["node_modules", "*.log", "build"] diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 2b0a4d52..ddbb58ca 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -208,11 +208,9 @@ def _make_config_with_project( ) -> tuple[SerenaConfig, str]: """Create a SerenaConfig with a single registered project and return (config, project_name).""" config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, log_level=logging.ERROR, language_backend=global_backend, - ) + ).with_headless_mode_overrides() project = Project( project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), project_config=ProjectConfig( @@ -252,11 +250,9 @@ class TestEffectiveLanguageBackend: def test_no_project_uses_global_backend(self): """When no startup project is provided, effective backend is the global one.""" config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, log_level=logging.ERROR, language_backend=LanguageBackend.LSP, - ) + ).with_headless_mode_overrides() agent = SerenaAgent(project=None, serena_config=config) try: assert agent.get_language_backend() == LanguageBackend.LSP @@ -338,90 +334,68 @@ class TestGetConfiguredProjectSerenaFolder: """Tests for SerenaConfig.get_configured_project_serena_folder (pure template resolution).""" def test_default_location(self): - config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, - ) + config = SerenaConfig().with_headless_mode_overrides() result = config.get_configured_project_serena_folder("/home/user/myproject") assert result == os.path.abspath("/home/user/myproject/.serena") def test_custom_location_with_project_folder_name(self): config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="/projects-metadata/$projectFolderName/.serena", - ) + ).with_headless_mode_overrides() result = config.get_configured_project_serena_folder("/home/user/myproject") assert result == os.path.abspath("/projects-metadata/myproject/.serena") def test_custom_location_with_project_dir(self): config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="$projectDir/.custom-serena", - ) + ).with_headless_mode_overrides() result = config.get_configured_project_serena_folder("/home/user/myproject") assert result == os.path.abspath("/home/user/myproject/.custom-serena") def test_custom_location_with_both_placeholders(self): config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="/data/$projectFolderName/$projectDir/.serena", - ) + ).with_headless_mode_overrides() result = config.get_configured_project_serena_folder("/home/user/proj") assert result == os.path.abspath("/data/proj/home/user/proj/.serena") def test_default_field_value(self): - config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, - ) + config = SerenaConfig().with_headless_mode_overrides() assert config.project_serena_folder_location == DEFAULT_PROJECT_SERENA_FOLDER_LOCATION def test_rejects_unknown_placeholder(self): config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="$projectDir/$unknownVar/.serena", - ) + ).with_headless_mode_overrides() with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$unknownVar'"): config.get_configured_project_serena_folder("/home/user/myproject") def test_rejects_typo_projectDirs(self): """$projectDirs should not be silently treated as $projectDir + 's'.""" config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="$projectDirs/.serena", - ) + ).with_headless_mode_overrides() with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$projectDirs'"): config.get_configured_project_serena_folder("/home/user/myproject") def test_rejects_typo_projectfoldername_lowercase(self): config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="/data/$projectfoldername/.serena", - ) + ).with_headless_mode_overrides() with pytest.raises(SerenaConfigError, match=r"Unknown placeholder '\$projectfoldername'"): config.get_configured_project_serena_folder("/home/user/myproject") def test_no_placeholders_is_valid(self): config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="/fixed/path/.serena", - ) + ).with_headless_mode_overrides() result = config.get_configured_project_serena_folder("/home/user/myproject") assert result == os.path.abspath("/fixed/path/.serena") def test_error_message_lists_supported_placeholders(self): config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="$bogus/.serena", - ) + ).with_headless_mode_overrides() with pytest.raises(SerenaConfigError, match=r"\$projectDir.*\$projectFolderName|\$projectFolderName.*\$projectDir"): config.get_configured_project_serena_folder("/home/user/myproject") @@ -452,7 +426,7 @@ class TestProjectSerenaDataFolder: return project def test_default_config_creates_in_project_dir(self): - config = SerenaConfig(gui_log_window=False, web_dashboard=False) + config = SerenaConfig().with_headless_mode_overrides() project = self._make_project(config) expected = os.path.abspath(str(self.project_path / SERENA_MANAGED_DIR_NAME)) assert project.path_to_serena_data_folder() == expected @@ -461,10 +435,8 @@ class TestProjectSerenaDataFolder: custom_base = Path(self.test_dir) / "metadata" custom_base.mkdir() config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location=str(custom_base) + "/$projectFolderName/.serena", - ) + ).with_headless_mode_overrides() project = self._make_project(config) expected = os.path.abspath(str(custom_base / "myproject" / ".serena")) assert project.path_to_serena_data_folder() == expected @@ -474,10 +446,8 @@ class TestProjectSerenaDataFolder: existing_serena = self.project_path / SERENA_MANAGED_DIR_NAME existing_serena.mkdir() config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location="/nonexistent/path/$projectFolderName/.serena", - ) + ).with_headless_mode_overrides() project = self._make_project(config) assert project.path_to_serena_data_folder() == str(existing_serena) @@ -491,10 +461,8 @@ class TestProjectSerenaDataFolder: custom_serena.mkdir(parents=True) config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, project_serena_folder_location=str(custom_base) + "/$projectFolderName/.serena", - ) + ).with_headless_mode_overrides() project = self._make_project(config) assert project.path_to_serena_data_folder() == str(custom_serena) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index c1e2578f..5bbd0a6d 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -758,7 +758,7 @@ SAFE_DELETE_SUCCEEDS_CASES = [ @pytest.fixture def serena_config(): - config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) + config = SerenaConfig(log_level=logging.ERROR).with_headless_mode_overrides() # Create test projects for all supported languages test_projects = [] @@ -867,7 +867,7 @@ class TestSerenaAgent: * an invalid project path is specified at startup All cases must not raise an exception. """ - serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False) + serena_config = SerenaConfig().with_headless_mode_overrides() SerenaAgent(project=project, serena_config=serena_config) def _symbol_matches_expected_name(self, symbol: dict, expected_name: str) -> bool: diff --git a/test/serena/test_tool_parameter_types.py b/test/serena/test_tool_parameter_types.py index 1299cb3a..42b8948a 100644 --- a/test/serena/test_tool_parameter_types.py +++ b/test/serena/test_tool_parameter_types.py @@ -13,7 +13,7 @@ def test_all_tool_parameters_have_type(context): For every tool exposed by Serena, ensure that the generated Open‑AI schema contains a ``type`` entry for each parameter. """ - cfg = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) + cfg = SerenaConfig(log_level=logging.ERROR).with_headless_mode_overrides() registry = ToolRegistry() cfg.included_optional_tools = tuple(registry.get_tool_names_optional()) factory = SerenaMCPFactory(transport="stdio", context=context) From bd322fcb5e4e210b20d0b63b40400acd02656d93 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 10:33:34 +0200 Subject: [PATCH 022/170] Bump the uv group across 1 directory with 4 updates (#1584) Bumps the uv group with 4 updates in the / directory: [starlette](https://github.com/Kludex/starlette), [python-multipart](https://github.com/Kludex/python-multipart), [cryptography](https://github.com/pyca/cryptography) and [tornado](https://github.com/tornadoweb/tornado). Updates `starlette` from 1.0.1 to 1.3.1 - [Release notes](https://github.com/Kludex/starlette/releases) - [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md) - [Commits](https://github.com/Kludex/starlette/compare/1.0.1...1.3.1) Updates `python-multipart` from 0.0.27 to 0.0.31 - [Release notes](https://github.com/Kludex/python-multipart/releases) - [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md) - [Commits](https://github.com/Kludex/python-multipart/compare/0.0.27...0.0.31) Updates `cryptography` from 46.0.7 to 48.0.1 - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.7...48.0.1) Updates `tornado` from 6.5.6 to 6.5.7 - [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst) - [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.6...v6.5.7) --- updated-dependencies: - dependency-name: starlette dependency-version: 1.3.1 dependency-type: direct:production dependency-group: uv - dependency-name: python-multipart dependency-version: 0.0.31 dependency-type: direct:production dependency-group: uv - dependency-name: cryptography dependency-version: 48.0.1 dependency-type: direct:production dependency-group: uv - dependency-name: tornado dependency-version: 6.5.7 dependency-type: direct:production dependency-group: uv ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 8 +-- uv.lock | 142 ++++++++++++++++++++++++------------------------- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6d47682..b71e2535 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,10 +45,10 @@ dependencies = [ # Exact pins because uvx installs from git, ignoring the lock file. "urllib3==2.7.0", "werkzeug==3.1.7", - "starlette==1.0.1", - "python-multipart==0.0.27", + "starlette==1.3.1", + "python-multipart==0.0.31", "filelock==3.25.2", - "cryptography==46.0.7", + "cryptography==48.0.1", "regex==2026.2.28", "pythonnet==3.1.0 ; sys_platform == 'win32'", "oslex==2.0.0", @@ -92,7 +92,7 @@ dev = [ "pytest-timeout==2.4.0", # Transitive dev deps pinned for security (dependabot alerts). # Exact pins because uvx installs from git, ignoring the lock file. - "tornado==6.5.6", + "tornado==6.5.7", "wheel==0.46.3", "pyasn1==0.6.3", "PyJWT==2.12.0", diff --git a/uv.lock b/uv.lock index 1dd9c4e4..6c02bafa 100644 --- a/uv.lock +++ b/uv.lock @@ -451,61 +451,61 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.7" +version = "48.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, - { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, - { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, - { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, - { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/eb4e394e587341fdad09a09101fa76478ead3a78b0ad63e55c22f0d75c02/cryptography-48.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:08a597acce1ff37f347400087776599e2348a3a8bc53b44120e463cd274efe4a", size = 3951747, upload-time = "2026-06-09T22:31:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8c/ce3823c06c2804f194f9e64f0d67fa3f4094a39f2bb1a990cd03603af8fc/cryptography-48.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6184ca7b174f28d7c703f1290d4b297217c45355f77a98f67e9b7f14549ac54a", size = 3742204, upload-time = "2026-06-09T22:31:34.773Z" }, ] [[package]] @@ -2303,11 +2303,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.27" +version = "0.0.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, ] [[package]] @@ -2909,7 +2909,7 @@ requires-dist = [ { name = "agno", marker = "extra == 'agno'", specifier = "==2.5.10" }, { name = "anthropic", specifier = "==0.59.0" }, { name = "beautifulsoup4", specifier = "==4.14.2" }, - { name = "cryptography", specifier = "==46.0.7" }, + { name = "cryptography", specifier = "==48.0.1" }, { name = "docstring-parser", specifier = "==0.17.0" }, { name = "dotenv", specifier = "==0.9.9" }, { name = "filelock", specifier = "==3.25.2" }, @@ -2937,7 +2937,7 @@ requires-dist = [ { 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 = "python-multipart", specifier = "==0.0.31" }, { name = "pythonnet", marker = "sys_platform == 'win32'", specifier = "==3.1.0" }, { name = "pywebview", specifier = "==6.2" }, { name = "pyyaml", specifier = "==6.0.2" }, @@ -2951,11 +2951,11 @@ requires-dist = [ { name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = "==2.0.0" }, { name = "sphinx-toolbox", marker = "extra == 'dev'", specifier = "==3.7.0" }, { name = "sqlalchemy", marker = "extra == 'agno'", specifier = "==2.0.41" }, - { name = "starlette", specifier = "==1.0.1" }, + { name = "starlette", specifier = "==1.3.1" }, { name = "syrupy", marker = "extra == 'dev'", specifier = "==4.9.1" }, { name = "tiktoken", specifier = "==0.12.0" }, { name = "toml-sort", marker = "extra == 'dev'", specifier = "==0.24.2" }, - { name = "tornado", marker = "extra == 'dev'", specifier = "==6.5.6" }, + { name = "tornado", marker = "extra == 'dev'", specifier = "==6.5.7" }, { name = "tqdm", specifier = "==4.67.1" }, { name = "types-pyyaml", specifier = "==6.0.12.20250516" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.20250516" }, @@ -3429,15 +3429,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.0.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -3547,19 +3547,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.6" +version = "6.5.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" }, - { url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" }, - { url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" }, - { url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, ] [[package]] From 9a4f61af60a00aea13a90cd879be619a909db877 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:36:39 +0000 Subject: [PATCH 023/170] Bump the uv group across 1 directory with 2 updates Bumps the uv group with 2 updates in the / directory: [msgpack](https://github.com/msgpack/msgpack-python) and [pydantic-settings](https://github.com/pydantic/pydantic-settings). Updates `msgpack` from 1.1.2 to 1.2.1 - [Release notes](https://github.com/msgpack/msgpack-python/releases) - [Changelog](https://github.com/msgpack/msgpack-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/msgpack/msgpack-python/compare/v1.1.2...v1.2.1) Updates `pydantic-settings` from 2.13.1 to 2.14.2 - [Release notes](https://github.com/pydantic/pydantic-settings/releases) - [Commits](https://github.com/pydantic/pydantic-settings/compare/v2.13.1...v2.14.2) --- updated-dependencies: - dependency-name: msgpack dependency-version: 1.2.1 dependency-type: indirect dependency-group: uv - dependency-name: pydantic-settings dependency-version: 2.14.2 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] --- uv.lock | 110 ++++++++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 50 deletions(-) diff --git a/uv.lock b/uv.lock index 6c02bafa..27c9ae97 100644 --- a/uv.lock +++ b/uv.lock @@ -1415,55 +1415,65 @@ wheels = [ [[package]] name = "msgpack" -version = "1.1.2" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, - { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, - { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, - { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, - { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, - { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, - { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, - { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, - { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, - { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, - { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, - { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, - { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, - { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, - { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, - { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, - { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, - { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, - { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, - { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, - { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, - { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, - { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] [[package]] @@ -2011,16 +2021,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] From 4160732c7d2fc2bc37695e8262494e49477dd8f3 Mon Sep 17 00:00:00 2001 From: Andrew Newell Date: Tue, 23 Jun 2026 15:11:52 -0400 Subject: [PATCH 024/170] Fix dangling registered project poisoning project lookup/activation A registered project whose root directory was deleted (e.g. a removed git worktree) would raise FileNotFoundError from RegisteredProject.matches_root_path (via Path.samefile). Because get_registered_project iterates matches_root_path over every registered project, one dangling entry aborted activation of all other, valid projects until an MCP server restart. matches_root_path now treats a non-existent directory as a non-match instead of letting the filesystem error propagate. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ src/serena/config/serena_config.py | 9 +++-- test/serena/config/test_serena_config.py | 43 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74728305..48c723af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools - Make tool call errors surface explicitly as errors at the MCP protocol level + - Fix: a registered project whose root directory was deleted while Serena was already running could break + `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` * Language Servers: - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 8f2c3522..aaf17fd4 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -705,9 +705,14 @@ class RegisteredProject(ToStringMixin): Check if the given path matches the project root path. :param path: the path to check - :return: True if the path matches the project root, False otherwise + :return: True if the path matches the project root, False otherwise (including the case + where this project's root directory no longer exists, e.g. a removed git worktree) """ - return self.project_root.samefile(Path(path).resolve()) + try: + return self.project_root.samefile(Path(path).resolve()) + except OSError: + # typically raised if the path does not exist (e.g., a removed git worktree) + return False def get_project_instance(self, serena_config: "SerenaConfig") -> "Project": """ diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 2b0a4d52..4848e8a1 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -569,6 +569,49 @@ class TestSerenaConfigFromConfigFileRobustness: ) +class TestGetRegisteredProjectWithDanglingProject: + """A registered project whose root directory was deleted (e.g. a removed git + worktree) must not break lookup/activation of other, valid projects. + + Reproduces the bug where ``get_registered_project`` iterates over every + registered project and ``RegisteredProject.matches_root_path`` raises + ``FileNotFoundError`` for the dangling project, aborting the whole lookup. + """ + + def setup_method(self): + self.test_dir = Path(tempfile.mkdtemp()) + + def teardown_method(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_project_dir(self, name: str) -> Path: + project_dir = self.test_dir / name + (project_dir / SERENA_MANAGED_DIR_NAME).mkdir(parents=True) + (project_dir / SERENA_MANAGED_DIR_NAME / "project.yml").write_text(f'project_name: "{name}"\nlanguages: ["python"]\n') + return project_dir + + def test_dangling_project_does_not_break_lookup_of_valid_project(self): + config = create_default_serena_config() + dangling_dir = self._make_project_dir("dangling_project") + valid_dir = self._make_project_dir("valid_project") + + # Register the dangling project FIRST so the path-matching loop hits the + # deleted directory before reaching the valid project. + config.projects = [ + RegisteredProject.from_project_root(dangling_dir, serena_config=config), + RegisteredProject.from_project_root(valid_dir, serena_config=config), + ] + + # Delete the dangling project's directory out from under Serena. + shutil.rmtree(dangling_dir) + + # Looking up the valid project by path must succeed, not raise + # FileNotFoundError on the deleted dangling project's path. + result = config.get_registered_project(str(valid_dir)) + assert result is not None + assert result.project_name == "valid_project" + + class TestMemoriesManagerCustomPath: """Tests for MemoriesManager with a custom serena data folder.""" From 59df80cb82b83bead16baf2156f19c275fa00568 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 24 Jun 2026 14:03:44 +0200 Subject: [PATCH 025/170] SolidLanguageServer: Account for exceptions in context managers * open_file * start_server_context --- src/solidlsp/ls.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index ecf1dea7..c311c0db 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1258,8 +1258,10 @@ class SolidLanguageServer(ABC): @contextmanager def start_server_context(self) -> Iterator["SolidLanguageServer"]: self.start() - yield self - self.stop() + try: + yield self + finally: + self.stop() @abstractmethod def _start_server(self) -> None: @@ -1307,8 +1309,6 @@ class SolidLanguageServer(ABC): fb.ref_count += 1 if open_in_ls: fb.ensure_open_in_ls() - yield fb - fb.ref_count -= 1 else: version = 0 language_id = self._get_language_id_for_file(relative_file_path) @@ -1323,12 +1323,14 @@ class SolidLanguageServer(ABC): open_in_ls=open_in_ls, ) self.open_file_buffers[uri] = fb - yield fb - fb.ref_count -= 1 - if self.open_file_buffers[uri].ref_count == 0: - self.open_file_buffers[uri].close() - del self.open_file_buffers[uri] + try: + yield fb + finally: + fb.ref_count -= 1 + if fb.ref_count == 0: + fb.close() + del self.open_file_buffers[uri] @contextmanager def _open_file_context( From 5d44d017c8d3b21dbd16e03241239095805945af Mon Sep 17 00:00:00 2001 From: BBP <142549804+KoscheiiB@users.noreply.github.com> Date: Thu, 25 Jun 2026 11:21:39 +0200 Subject: [PATCH 026/170] feat(solidlsp): add LaTeX language server via texlab (#1600) Register the LATEX language (.tex/.bib/.sty/.cls) backed by texlab, downloaded as a sha256-verified single-binary from GitHub Releases (v5.25.1) across macOS/Linux/Windows. Document symbols expose the sectioning hierarchy; definitions/references cover labels and citations. Includes a smoke-test fixture repo and basic document-symbol tests. Co-authored-by: KoscheiiB --- CHANGELOG.md | 5 + README.md | 2 +- docs/01-about/020_programming-languages.md | 4 + pyproject.toml | 1 + .../texlab_language_server.py | 199 ++++++++++++++++++ src/solidlsp/ls_config.py | 14 +- test/conftest.py | 1 + .../repos/latex/test_repo/.gitignore | 7 + test/resources/repos/latex/test_repo/main.tex | 35 +++ .../repos/latex/test_repo/references.bib | 13 ++ .../latex/test_repo/sections/background.tex | 9 + .../repos/latex/test_repo/slides.tex | 33 +++ test/serena/test_serena_agent.py | 11 + test/solidlsp/latex/test_latex_basic.py | 37 ++++ test/solidlsp/latex/test_latex_beamer.py | 59 ++++++ test/solidlsp/latex/test_latex_references.py | 92 ++++++++ 16 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 src/solidlsp/language_servers/texlab_language_server.py create mode 100644 test/resources/repos/latex/test_repo/.gitignore create mode 100644 test/resources/repos/latex/test_repo/main.tex create mode 100644 test/resources/repos/latex/test_repo/references.bib create mode 100644 test/resources/repos/latex/test_repo/sections/background.tex create mode 100644 test/resources/repos/latex/test_repo/slides.tex create mode 100644 test/solidlsp/latex/test_latex_basic.py create mode 100644 test/solidlsp/latex/test_latex_beamer.py create mode 100644 test/solidlsp/latex/test_latex_references.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c723af..db2ca579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,11 @@ Status of the `main` branch. Changes prior to the next official version change w so they can no longer inherit and clobber Serena's stdin, which is the JSON-RPC pipe under the stdio transport. #1577 - Improve quoting of arguments in shell executions + - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). Auto-downloads a + SHA-256-verified prebuilt texlab binary (osx-arm64/x64, linux-arm64/x64, win-x64); covers `.tex`, `.bib`, + `.sty`, and `.cls` files. Provides sectioning document symbols (including beamer frames), label/citation + definitions, and `\ref`/`\cite` references. Must be explicitly enabled via language `latex`. texlab is + GPL-3.0 and runs as a separate downloaded process. * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/README.md b/README.md index 44cdee0f..76c0e383 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, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +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, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, TOML, TypeScript, WGSL, YAML, and Zig. ### The Serena JetBrains Plugin diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 6f435c2f..da560a2d 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -97,6 +97,10 @@ Some languages require additional installations or setup steps, as noted. * **Julia** * **Kotlin** (uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear) +* **LaTeX** + (experimental; must be explicitly enabled via language `latex`; uses [texlab](https://github.com/latex-lsp/texlab), + auto-downloaded as a SHA-256-verified prebuilt binary; supports `.tex`, `.bib`, `.sty`, and `.cls` files; texlab is + GPL-3.0 and runs as a separate downloaded process) * **Lean 4** (requires `lean` and `lake` installed via [elan](https://github.com/leanprover/elan); uses the built-in Lean 4 LSP; the project must be a Lake project with `lake build` run before use) diff --git a/pyproject.toml b/pyproject.toml index b71e2535..118a786a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -337,6 +337,7 @@ markers = [ "fsharp: language server running for F#", "rego: language server running for Rego", "markdown: language server running for Markdown", + "latex: language server running for LaTeX", "julia: Julia language server tests", "fortran: language server running for Fortran", "haskell: Haskell language server tests", diff --git a/src/solidlsp/language_servers/texlab_language_server.py b/src/solidlsp/language_servers/texlab_language_server.py new file mode 100644 index 00000000..ea95f6e6 --- /dev/null +++ b/src/solidlsp/language_servers/texlab_language_server.py @@ -0,0 +1,199 @@ +""" +Provides LaTeX specific instantiation of the LanguageServer class using texlab. +texlab is downloaded as a prebuilt binary from the latex-lsp/texlab GitHub releases. +""" + +import logging +import os +from typing import cast + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import PathUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +TEXLAB_VERSION = "5.25.1" +TEXLAB_BASE_URL = f"https://github.com/latex-lsp/texlab/releases/download/v{TEXLAB_VERSION}" +TEXLAB_ALLOWED_HOSTS = ( + "github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +) +TEXLAB_SHA256_BY_PLATFORM = { + "osx-arm64": "3755e9d1d4ad0b25135bdacd2fb453a612e88f48133185f96d660fa550398f66", + "osx-x64": "11289a231f0cf382857a6a4a2eda1ba9f4f4e950af343b455797e3922d13b1ea", + "linux-arm64": "e0d8e0b27b2e6e3526fa5019323bb3fddb1202a0f0049e527672b5ff323cc15e", + "linux-x64": "c8260b2fd2849cbad7d1f54c4ffa0389f34664b049392107bc4f7f9c8ec542ba", + "win-x64": "aa5fc1fe6004c17cd83086a57a8c8f28bb3f360914872711bfbb83490dc3c19e", +} + + +class TexlabLanguageServer(SolidLanguageServer): + """ + Provides LaTeX specific instantiation of the LanguageServer class using texlab. + + Symbol navigation maps onto LaTeX structure: document symbols are the sectioning + hierarchy, definitions/references cover labels and citations. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["_minted", "_build", "build", "out", "auto"] + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + """Download and install texlab for the current platform if not already present.""" + platform_id = PlatformUtils.get_platform_id() + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="Texlab", + description="texlab for macOS (ARM64)", + url=f"{TEXLAB_BASE_URL}/texlab-aarch64-macos.tar.gz", + platform_id="osx-arm64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["osx-arm64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for macOS (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-macos.tar.gz", + platform_id="osx-x64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["osx-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Linux (ARM64)", + url=f"{TEXLAB_BASE_URL}/texlab-aarch64-linux.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["linux-arm64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Linux (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-linux.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["linux-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Windows (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-windows.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="texlab.exe", + sha256=TEXLAB_SHA256_BY_PLATFORM["win-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + ] + ) + dependency = deps.get_single_dep_for_current_platform() + install_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), f"texlab-{TEXLAB_VERSION}") + texlab_executable_path = deps.binary_path(install_dir) + if not os.path.exists(texlab_executable_path): + log.info(f"Downloading texlab from {dependency.url}") + deps.install(install_dir) + + assert os.path.exists(texlab_executable_path), f"texlab executable not found at {texlab_executable_path}" + + if platform_id.value != "win-x64": + os.chmod(texlab_executable_path, 0o755) + + return texlab_executable_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TexlabLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + texlab_executable_path = self._setup_runtime_dependencies(solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=texlab_executable_path, cwd=repository_root_path), + "latex", + solidlsp_settings, + ) + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """Returns the initialize params for the texlab Language Server.""" + root_uri = PathUtils.path_to_uri(repository_absolute_path) + result = { + "processId": os.getpid(), + "locale": "en", + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "rename": {"dynamicRegistration": True}, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + "workspaceFolders": [ + { + "name": os.path.basename(repository_absolute_path), + "uri": root_uri, + } + ], + } + return cast(InitializeParams, result) + + def _start_server(self) -> None: + """Start the texlab server process.""" + + 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 + + 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) + + log.info("Starting texlab server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to texlab and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + assert "textDocumentSync" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 8f593833..2c1850fa 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -159,6 +159,11 @@ class Language(str, Enum): Must be explicitly specified as the main language, not auto-detected. This is an edge case primarily useful when working on documentation-heavy projects. """ + LATEX = "latex" + """texlab language server for LaTeX/BibTeX (experimental). + Must be explicitly specified as the main language, not auto-detected. + Provides sectioning-hierarchy document symbols plus label/citation definitions and references. + """ YAML = "yaml" """YAML language server (experimental). Must be explicitly specified as the main language, not auto-detected. @@ -248,6 +253,7 @@ class Language(str, Enum): self.RUBY_SOLARGRAPH, self.PHP_PHPACTOR, self.MARKDOWN, + self.LATEX, self.YAML, self.JSON, self.TOML, @@ -263,7 +269,7 @@ class Language(str, Enum): """Whether the supported language should be considered a programming language. Solidlsp supports languages like markdown or json, this method returns False for them. """ - return self not in frozenset((self.MARKDOWN, self.JSON, self.TOML, self.YAML, self.ANSIBLE)) + return self not in frozenset((self.MARKDOWN, self.LATEX, self.JSON, self.TOML, self.YAML, self.ANSIBLE)) def __str__(self) -> str: return self.value @@ -429,6 +435,8 @@ class Language(str, Enum): return FilenameMatcher(".rego") case self.MARKDOWN: return FilenameMatcher(".md", ".markdown") + case self.LATEX: + return FilenameMatcher(".tex", ".bib", ".sty", ".cls") case self.SCALA: return FilenameMatcher(".scala", ".sbt") case self.JULIA: @@ -681,6 +689,10 @@ class Language(str, Enum): from solidlsp.language_servers.marksman import Marksman return Marksman + case self.LATEX: + from solidlsp.language_servers.texlab_language_server import TexlabLanguageServer + + return TexlabLanguageServer case self.R: from solidlsp.language_servers.r_language_server import RLanguageServer diff --git a/test/conftest.py b/test/conftest.py index de0ac507..672a255b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -256,6 +256,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.JAVA: [pytest.mark.java], Language.KOTLIN: [pytest.mark.kotlin, pytest.mark.skipif(is_ci, reason="Kotlin LSP JVM crashes on restart in CI")], Language.LEAN4: [pytest.mark.lean4, pytest.mark.skipif(_sh.which("lean") is None, reason="Lean is not installed")], + Language.LATEX: [pytest.mark.latex], Language.MSL: [pytest.mark.msl], Language.PHP: [pytest.mark.php], Language.PHP_PHPACTOR: [pytest.mark.php], diff --git a/test/resources/repos/latex/test_repo/.gitignore b/test/resources/repos/latex/test_repo/.gitignore new file mode 100644 index 00000000..531e44bf --- /dev/null +++ b/test/resources/repos/latex/test_repo/.gitignore @@ -0,0 +1,7 @@ +*.aux +*.log +*.out +*.pdf +*.toc +*.synctex.gz +build/ diff --git a/test/resources/repos/latex/test_repo/main.tex b/test/resources/repos/latex/test_repo/main.tex new file mode 100644 index 00000000..6b0cb8cf --- /dev/null +++ b/test/resources/repos/latex/test_repo/main.tex @@ -0,0 +1,35 @@ +\documentclass{article} +\usepackage{amsmath} + +\title{Serena Texlab Smoke Test} +\author{Test Author} + +\begin{document} +\maketitle + +\section{Introduction} +\label{sec:intro} +This is the introduction. It points forward to Section~\ref{sec:methods}. + +\section{Methods} +\label{sec:methods} +The methods build on Section~\ref{sec:intro}. + +\subsection{Implementation Details} +\label{sec:details} +Details that refer back to Section~\ref{sec:methods} once more. + +\section{Conclusion} +\label{sec:conclusion} +Wrapping up, see Section~\ref{sec:details}. + +\input{sections/background.tex} + +For the broader context behind these methods, see Section~\ref{sec:background}. +The typesetting system itself is described by Lamport~\cite{lamport1994} and +Knuth~\cite{knuth1984}. + +\bibliographystyle{plain} +\bibliography{references} + +\end{document} diff --git a/test/resources/repos/latex/test_repo/references.bib b/test/resources/repos/latex/test_repo/references.bib new file mode 100644 index 00000000..25931bea --- /dev/null +++ b/test/resources/repos/latex/test_repo/references.bib @@ -0,0 +1,13 @@ +@book{knuth1984, + author = {Knuth, Donald E.}, + title = {The {TeXbook}}, + publisher = {Addison-Wesley}, + year = {1984}, +} + +@book{lamport1994, + author = {Lamport, Leslie}, + title = {{LaTeX}: A Document Preparation System}, + publisher = {Addison-Wesley}, + year = {1994}, +} diff --git a/test/resources/repos/latex/test_repo/sections/background.tex b/test/resources/repos/latex/test_repo/sections/background.tex new file mode 100644 index 00000000..c9edfe82 --- /dev/null +++ b/test/resources/repos/latex/test_repo/sections/background.tex @@ -0,0 +1,9 @@ +\section{Background} +\label{sec:background} +The document model used here follows the sectioning structure of the TeX +typesetting system~\cite{knuth1984}. Cross-referencing between sections relies on +stable label keys, which is the behaviour this fixture exercises. + +\subsection{Prior Work} +\label{sec:prior-work} +A fuller description of the macro layer appears in the LaTeX manual~\cite{lamport1994}. diff --git a/test/resources/repos/latex/test_repo/slides.tex b/test/resources/repos/latex/test_repo/slides.tex new file mode 100644 index 00000000..d19f996d --- /dev/null +++ b/test/resources/repos/latex/test_repo/slides.tex @@ -0,0 +1,33 @@ +\documentclass{beamer} +\usetheme{default} + +\title{Serena Texlab Beamer Smoke Test} +\author{Test Author} + +\begin{document} + +\frame{\titlepage} + +\section{Overview} + +\begin{frame} +\frametitle{Introduction} +\label{frame:intro} +An introductory frame for the overview section. +\end{frame} + +\begin{frame} +\frametitle{Methodology} +\label{frame:method} +A frame describing the methodology, see Section~\ref{sec:results}. +\end{frame} + +\section{Results} +\label{sec:results} + +\begin{frame} +\frametitle{Findings} +A closing frame summarising the findings. +\end{frame} + +\end{document} diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 5bbd0a6d..5ab27115 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -517,6 +517,9 @@ FIND_SYMBOL_REFERENCES_CASES = [ FindSymbolCase( language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs" ).to_pytest_param(), + FindSymbolCase( + language=Language.LATEX, id="latex_methods_section", symbol_name="Methods", expected_kind="Module", expected_file="main.tex" + ).to_pytest_param(), ] FIND_REFERENCE_CASES = [ @@ -597,6 +600,13 @@ FIND_REFERENCE_CASES = [ ).to_pytest_param( pytest.mark.xfail(reason="F# language server is unreliable"), # See issue #1040 ), + FindReferenceCase( + language=Language.LATEX, + id="latex_background_refs", + symbol_name="Background", + definition_file="sections/background.tex", + reference_file="main.tex", + ).to_pytest_param(), ] FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [ @@ -779,6 +789,7 @@ def serena_config(): Language.HAXE, Language.LEAN4, Language.MSL, + Language.LATEX, ]: repo_path = get_repo_path(language) if repo_path.exists(): diff --git a/test/solidlsp/latex/test_latex_basic.py b/test/solidlsp/latex/test_latex_basic.py new file mode 100644 index 00000000..36cc67df --- /dev/null +++ b/test/solidlsp/latex/test_latex_basic.py @@ -0,0 +1,37 @@ +"""Basic tests for the texlab-based LaTeX language server.""" + +import pytest + +from serena.symbol import LanguageServerSymbol +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.latex +class TestLatexLanguageServerBasics: + """Basic functionality of the LaTeX (texlab) language server.""" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_document_symbols_are_sections(self, language_server: SolidLanguageServer) -> None: + """Sectioning commands should surface as document symbols.""" + symbols, _roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + assert len(symbols) > 0, "Should find at least some symbols in main.tex" + + names = {s.get("name", "") for s in symbols} + for expected in ("Introduction", "Methods", "Conclusion"): + assert expected in names, f"Expected section '{expected}' among document symbols, got: {sorted(names)}" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_subsection_symbol_present(self, language_server: SolidLanguageServer) -> None: + """A nested subsection should also be exposed as a symbol.""" + symbols, _roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + names = {s.get("name", "") for s in symbols} + assert "Implementation Details" in names, f"Expected the subsection symbol, got: {sorted(names)}" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_subsection_name_path_nests_under_section(self, language_server: SolidLanguageServer) -> None: + """A subsection's name path nests under its parent section ("section/subsection").""" + _symbols, roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + matches = [m for root in roots for m in LanguageServerSymbol(root).find("Methods/Implementation Details")] + assert len(matches) == 1, matches + assert matches[0].get_name_path() == "Methods/Implementation Details" diff --git a/test/solidlsp/latex/test_latex_beamer.py b/test/solidlsp/latex/test_latex_beamer.py new file mode 100644 index 00000000..adc6daea --- /dev/null +++ b/test/solidlsp/latex/test_latex_beamer.py @@ -0,0 +1,59 @@ +r"""Beamer presentation support for the LaTeX (texlab) language server. + +Beamer frames surface as ``Frame: `` document symbols nested under their +section, and ``\ref`` resolution works inside frames. +""" + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import Location +from test.solidlsp.conftest import read_repo_file + +SLIDES = "slides.tex" + + +def _rel(location: Location) -> str: + relative_path = location["relativePath"] + assert relative_path is not None, location + return relative_path.replace("\\", "/") + + +@pytest.mark.latex +class TestLatexBeamer: + """texlab handling of a beamer presentation (sections and frames).""" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_beamer_sections_and_frames_are_symbols(self, language_server: SolidLanguageServer) -> None: + r"""Beamer sections and ``\frametitle`` frames both surface as document symbols.""" + symbols, _roots = language_server.request_document_symbols(SLIDES).get_all_symbols_and_roots() + names = {s.get("name", "") for s in symbols} + for expected in ("Overview", "Results", "Frame: Introduction", "Frame: Methodology", "Frame: Findings"): + assert expected in names, f"Expected beamer symbol {expected!r}, got: {sorted(names)}" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_beamer_frames_nest_under_sections(self, language_server: SolidLanguageServer) -> None: + r"""Frames are children of the section they appear in.""" + _symbols, roots = language_server.request_document_symbols(SLIDES).get_all_symbols_and_roots() + roots_by_name = {root.get("name"): root for root in roots} + + overview = roots_by_name.get("Overview") + assert overview is not None, [root.get("name") for root in roots] + overview_children = {child.get("name") for child in overview.get("children", [])} + assert overview_children == {"Frame: Introduction", "Frame: Methodology"}, overview_children + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_beamer_frame_ref_resolves_to_section(self, language_server: SolidLanguageServer) -> None: + r"""A ``\ref`` inside a beamer frame resolves to the section it targets.""" + content = read_repo_file(language_server, SLIDES) + ref = find_text_coordinates(content, r"see Section~\\ref\{(sec:results)\}", require_unique=True) + section = find_text_coordinates(content, r"\\section\{(Results)\}", require_unique=True) + assert ref is not None and section is not None + + definitions = language_server.request_definition(SLIDES, ref.line, ref.col) + + assert len(definitions) == 1, definitions + assert _rel(definitions[0]) == "slides.tex" + assert definitions[0]["range"]["start"]["line"] == section.line diff --git a/test/solidlsp/latex/test_latex_references.py b/test/solidlsp/latex/test_latex_references.py new file mode 100644 index 00000000..0cfd1865 --- /dev/null +++ b/test/solidlsp/latex/test_latex_references.py @@ -0,0 +1,92 @@ +r"""Reference and definition resolution for the LaTeX (texlab) language server. + +Exercises within-file and cross-file ``\ref`` -> ``\label`` resolution and +``\cite`` -> BibTeX entry resolution over the latex test repository. +""" + +import os + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import Location +from test.solidlsp.conftest import read_repo_file + +MAIN = "main.tex" +BACKGROUND = os.path.join("sections", "background.tex") +BIB = "references.bib" + + +def _coords(language_server: SolidLanguageServer, relative_path: str, regex: str): + coords = find_text_coordinates(read_repo_file(language_server, relative_path), regex, require_unique=True) + assert coords is not None, f"pattern {regex!r} not found in {relative_path}" + return coords + + +def _rel(location: Location) -> str: + relative_path = location["relativePath"] + assert relative_path is not None, location + return relative_path.replace("\\", "/") + + +@pytest.mark.latex +class TestLatexReferences: + """texlab reference/definition resolution within and across files.""" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_within_file_ref_resolves_to_section(self, language_server: SolidLanguageServer) -> None: + r"""A ``\ref`` resolves to the sectioning command labelled in the same file.""" + ref = _coords(language_server, MAIN, r"forward to Section~\\ref\{(sec:methods)\}") + section = _coords(language_server, MAIN, r"\\section\{(Methods)\}") + + definitions = language_server.request_definition(MAIN, ref.line, ref.col) + + assert len(definitions) == 1, definitions + assert _rel(definitions[0]) == "main.tex" + assert definitions[0]["range"]["start"]["line"] == section.line + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_within_file_references_list_all_uses(self, language_server: SolidLanguageServer) -> None: + r"""Requesting references on a within-file label returns both ``\ref`` uses.""" + label = _coords(language_server, MAIN, r"\\label\{(sec:methods)\}") + + references = language_server.request_references(MAIN, label.line, label.col) + + assert {_rel(ref) for ref in references} == {"main.tex"} + assert len(references) == 2, references + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_cross_file_ref_resolves_across_files(self, language_server: SolidLanguageServer) -> None: + r"""A ``\ref`` in main.tex resolves to a ``\label`` defined in another file.""" + ref = _coords(language_server, MAIN, r"see Section~\\ref\{(sec:background)\}") + section = _coords(language_server, BACKGROUND, r"\\section\{(Background)\}") + + definitions = language_server.request_definition(MAIN, ref.line, ref.col) + + assert len(definitions) == 1, definitions + assert _rel(definitions[0]) == "sections/background.tex" + assert definitions[0]["range"]["start"]["line"] == section.line + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_cross_file_references_point_back_to_main(self, language_server: SolidLanguageServer) -> None: + r"""References on a cross-file label include the ``\ref`` site in main.tex.""" + label = _coords(language_server, BACKGROUND, r"\\label\{(sec:background)\}") + ref_in_main = _coords(language_server, MAIN, r"see Section~\\ref\{(sec:background)\}") + + references = language_server.request_references(BACKGROUND, label.line, label.col) + + assert any(_rel(ref) == "main.tex" and ref["range"]["start"]["line"] == ref_in_main.line for ref in references), references + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_citation_resolves_to_bib_entry(self, language_server: SolidLanguageServer) -> None: + r"""A ``\cite`` resolves to its entry in the BibTeX file.""" + cite = _coords(language_server, MAIN, r"Knuth~\\cite\{(knuth1984)\}") + entry = _coords(language_server, BIB, r"@book\{(knuth1984)") + + definitions = language_server.request_definition(MAIN, cite.line, cite.col) + + assert len(definitions) == 1, definitions + assert _rel(definitions[0]) == "references.bib" + assert definitions[0]["range"]["start"]["line"] == entry.line From 3975a91bfe70e14c5733d6b59fd7504578003894 Mon Sep 17 00:00:00 2001 From: PETRO YAKOVYSHYN <petroyakovyshyn@gmail.com> Date: Thu, 25 Jun 2026 02:23:25 -0700 Subject: [PATCH 027/170] Add Unreal Engine 5 specific ignores, raises, docs and tests for C/C++ (#1564) --- CHANGELOG.md | 8 + docs/01-about/020_programming-languages.md | 3 +- docs/03-special-guides/cpp_setup.md | 5 + .../unreal_engine_setup_guide_for_serena.md | 103 ++++++++++ pyproject.toml | 2 +- .../language_servers/ccls_language_server.py | 10 +- .../clangd_language_server.py | 22 ++- src/solidlsp/language_servers/common.py | 11 ++ .../TestGame/UHT/AbilityActor.generated.h | 9 + .../TestGame/UHT/AbilityComponent.gen.cpp | 22 +++ .../TestGame/UHT/AbilityComponent.generated.h | 10 + .../TestGame/UHT/AbilityTypes.generated.h | 8 + .../TestGame/UHT/Damageable.generated.h | 10 + .../TestGame/UHT/GameCharacter.generated.h | 10 + .../repos/cpp/ue_test_repo/MyGame.uproject | 4 + .../Source/TestGame/AbilityActor.cpp | 18 ++ .../Source/TestGame/AbilityActor.h | 24 +++ .../Source/TestGame/AbilityComponent.cpp | 16 ++ .../Source/TestGame/AbilityComponent.h | 40 ++++ .../Source/TestGame/AbilityTypes.h | 24 +++ .../ue_test_repo/Source/TestGame/Damageable.h | 20 ++ .../Source/TestGame/GameCharacter.cpp | 29 +++ .../Source/TestGame/GameCharacter.h | 39 ++++ .../Source/TestGame/TestGameLog.h | 5 + .../Source/TestGame/TestGameModule.cpp | 6 + .../Stubs/Components/ActorComponent.h | 10 + .../cpp/ue_test_repo/Stubs/Containers/Array.h | 11 ++ .../cpp/ue_test_repo/Stubs/Containers/Map.h | 12 ++ .../cpp/ue_test_repo/Stubs/Containers/Set.h | 11 ++ .../cpp/ue_test_repo/Stubs/CoreMinimal.h | 20 ++ .../repos/cpp/ue_test_repo/Stubs/CoreTypes.h | 41 ++++ .../Stubs/Delegates/DelegateCombinations.h | 81 ++++++++ .../cpp/ue_test_repo/Stubs/Engine/World.h | 9 + .../ue_test_repo/Stubs/GameFramework/Actor.h | 13 ++ .../Stubs/GameFramework/Character.h | 9 + .../ue_test_repo/Stubs/GameFramework/Pawn.h | 9 + .../ue_test_repo/Stubs/Logging/LogMacros.h | 13 ++ .../cpp/ue_test_repo/Stubs/Math/MathFwd.h | 25 +++ .../cpp/ue_test_repo/Stubs/Misc/Optional.h | 12 ++ .../Stubs/Modules/ModuleManager.h | 9 + .../Stubs/Templates/SharedPointer.h | 31 +++ .../ue_test_repo/Stubs/Templates/SubclassOf.h | 9 + .../ue_test_repo/Stubs/Templates/UniquePtr.h | 17 ++ .../ue_test_repo/Stubs/UObject/Interface.h | 8 + .../cpp/ue_test_repo/Stubs/UObject/Object.h | 15 ++ .../ue_test_repo/Stubs/UObject/ObjectMacros.h | 20 ++ .../ue_test_repo/Stubs/UObject/ObjectPtr.h | 13 ++ .../Stubs/UObject/SoftObjectPtr.h | 18 ++ .../Stubs/UObject/UObjectGlobals.h | 16 ++ .../Stubs/UObject/WeakObjectPtr.h | 12 ++ .../cpp/ue_test_repo/compile_commands.json | 22 +++ test/solidlsp/conftest.py | 34 ++++ test/solidlsp/cpp/test_cpp_unreal.py | 187 ++++++++++++++++++ test/solidlsp/cpp/test_cpp_unreal_unit.py | 60 ++++++ 54 files changed, 1193 insertions(+), 12 deletions(-) create mode 100644 docs/03-special-guides/unreal_engine_setup_guide_for_serena.md create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h create mode 100644 test/resources/repos/cpp/ue_test_repo/MyGame.uproject create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h create mode 100644 test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h create mode 100644 test/resources/repos/cpp/ue_test_repo/compile_commands.json create mode 100644 test/solidlsp/cpp/test_cpp_unreal.py create mode 100644 test/solidlsp/cpp/test_cpp_unreal_unit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db2ca579..7087b497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,14 @@ Status of the `main` branch. Changes prior to the next official version change w `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` * Language Servers: + - C/C++ (clangd): add Unreal Engine 5 fixture and tests verifying that reflection-macro + code (`UCLASS`, `UFUNCTION`, `UPROPERTY`, `GENERATED_BODY`) yields correct symbols, + references, definitions, and rename edits in hand-written sources (never in + UHT-generated files); add an Unreal Engine setup guide. + - C/C++ (clangd, ccls): detect Unreal Engine projects by a `.uproject` file at the project + root and skip the engine build/cache directories (`Binaries`, `DerivedDataCache`, + `Intermediate`, `Saved`) during indexing. clangd fails with a pointer to the Unreal Engine + setup guide when started in such a project without a `compile_commands.json`. #1566 - `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 diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index da560a2d..a9be9c7e 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -55,7 +55,8 @@ Some languages require additional installations or setup steps, as noted. * **C/C++** (by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`); for best results, provide a `compile_commands.json` at the repository root; - see the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details.) + see the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details; + for Unreal Engine 5 projects, see the [Unreal Engine Setup Guide](../03-special-guides/unreal_engine_setup_guide_for_serena).) * **Clojure** * **Crystal** (requires [Crystalline](https://github.com/elbywan/crystalline) language server to be installed and available on PATH; diff --git a/docs/03-special-guides/cpp_setup.md b/docs/03-special-guides/cpp_setup.md index c54f2105..dd5622dd 100644 --- a/docs/03-special-guides/cpp_setup.md +++ b/docs/03-special-guides/cpp_setup.md @@ -100,3 +100,8 @@ the language server is restarted. - Clangd official documentation: https://clangd.llvm.org/ - Clangd project setup: https://clangd.llvm.org/installation#project-setup - CCLS repository: https://github.com/MaskRay/ccls + +## Unreal Engine projects + +For Unreal Engine 5 projects (reflection macros, UnrealBuildTool), see the +[Unreal Engine Setup Guide](unreal_engine_setup_guide_for_serena.md). diff --git a/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md b/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md new file mode 100644 index 00000000..8f5fb6af --- /dev/null +++ b/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md @@ -0,0 +1,103 @@ +# Unreal Engine Setup Guide + +This guide explains how to prepare an Unreal Engine 5 C++ project so that Serena's +clangd-based C/C++ support can provide full code intelligence: symbol search, +cross-file references, and symbol-level editing in your hand-written sources. + +UE game code uses a macro-based reflection layer (`UCLASS`, `UFUNCTION`, `UPROPERTY`, +`GENERATED_BODY`) and engine types (`TArray`, `TMap`). clangd handles all of this, +provided it receives the compiler flags for your project via a `compile_commands.json` +at the project root. Unreal's build system (UnrealBuildTool) does not produce this +file by default; this guide shows how to obtain it. + +--- +## Prerequisites + +- An Unreal Engine 5 C++ project that has been **built at least once** (the build + generates the `*.generated.h` headers that your sources include). +- No additional language server: Serena downloads clangd automatically. +- clangd never compiles your code. The compilation database is only a list of flags. + +--- +## Getting a compilation database + +If clangd starts in a project that has a `.uproject` file but no `compile_commands.json`, +Serena fails the language server startup with an error that points back to this guide, +because clangd cannot resolve engine headers or reflection macros without the database. +Pick one of the following routes to create it. + +### Route 1: VSCode project files (no extra installs) + +UnrealBuildTool's VSCode project generator emits per-project compile commands. +Run it from your engine installation (VSCode itself is not required): + + <Engine>\Build\BatchFiles\Build.bat -projectfiles -project="<YourProject>.uproject" -game -VSCode + +This produces `.vscode/compileCommands_<YourProject>.json` inside your project. +Copy or symlink it to the project root as `compile_commands.json`. + +If you already use VSCode with UE, the file likely exists; the editor's +"Tools > Refresh Visual Studio Code Project" action maintains it. + +### Route 2: UnrealBuildTool's clang database mode (requires LLVM installed) + + <Engine>\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.exe -mode=GenerateClangDatabase -project="<YourProject>.uproject" <YourProject>Editor Win64 Development -OutputDir="<YourProject's directory>" + +This emits clang-native commands (cleanest flags for clangd) but requires a Clang +toolchain installed on Windows. + +--- +## Build artifact directories + +Generated reflection code (`*.gen.cpp`, `*.generated.h`) legitimately references your +functions, so symbol results could otherwise include hits inside `Intermediate/`. When a +`.uproject` file is present at the project root, the clangd and ccls language servers skip +UE's build and cache directories (`Binaries`, `DerivedDataCache`, `Intermediate`, `Saved`) +during indexing. This is automatic and needs no configuration. + +To exclude further paths, add them to `ignored_paths` in your project's +`.serena/project.yml`. + +--- +## Known behavior + +- **`GENERATED_BODY()` and `__LINE__`:** the macro expands using its line number. + After editing lines above it, clangd may report stale-macro diagnostics until the + next build regenerates headers. Symbol operations keep working, since clangd is + designed to operate on code with errors. +- **First index:** large projects take a few minutes to index once; afterwards + results are incremental. The index cache is kept under `.serena/.cache` inside + the project. +- **New `UFUNCTION`/`UCLASS` declarations** need a build before their generated + headers exist. +- **Symbol searches on large projects:** prefer passing `relative_path` to + `find_symbol`. An unscoped search visits every translation unit, and UE + files are expensive to parse because each pulls in large engine headers. + +--- +## Troubleshooting + +Extra flags are easiest to add via a `.clangd` file at the project root, e.g.: + + CompileFlags: + Add: [-D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH, -ferror-limit=200] + +- **`STL1000: Unexpected compiler version` errors:** recent MSVC STL headers + assert a minimum Clang version that may be newer than Serena's bundled clangd. + Defining `_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH` (see above) silences the + check; clangd only parses, so the mismatch is harmless. +- **Truncated symbol trees / symbols missing below a certain line:** clangd + aborts a file's parse after ~20 errors by default, which discards everything + declared after that point. Raising the limit with `-ferror-limit=200` keeps + the symbol tree intact even when diagnostics are noisy (common right after + edits, before the next UE build regenerates headers). +- **Stale results after changing the compilation database:** clangd's index + shards in `.serena/.cache` were built with the old flags. Delete that cache + directory and let the project re-index. + +--- +## Verifying the setup + +After activating the project in Serena, a symbol overview of any `UCLASS` header +should list the class with its `UFUNCTION` methods and `UPROPERTY` fields, and +references to a method should resolve to your `Source/` files only. diff --git a/pyproject.toml b/pyproject.toml index 118a786a..50df7387 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -368,4 +368,4 @@ markers = [ skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py' check-hidden = true ignore-regex = '\.\w+' -ignore-words-list = 'paket,EDN,als' +ignore-words-list = 'paket,EDN,als,ue' diff --git a/src/solidlsp/language_servers/ccls_language_server.py b/src/solidlsp/language_servers/ccls_language_server.py index 252839ab..a27d80d6 100644 --- a/src/solidlsp/language_servers/ccls_language_server.py +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -36,6 +36,7 @@ from typing import Any, cast from overrides import override +from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES, is_unreal_engine_project from solidlsp.ls import ( LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, @@ -71,10 +72,11 @@ class CCLS(SolidLanguageServer): @override def is_ignored_dirname(self, dirname: str) -> bool: - ignored_dirs = [ - ".ccls-cache", - ] - return super().is_ignored_dirname(dirname) or dirname in ignored_dirs + return ( + super().is_ignored_dirname(dirname) + or dirname == ".ccls-cache" + or (is_unreal_engine_project(self.repository_root_path) and dirname in UE_IGNORED_DIRNAMES) + ) class DependencyProvider(LanguageServerDependencyProviderSinglePath): def _get_or_install_core_dependency(self) -> str: diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 7b7d6839..66613b76 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -9,12 +9,14 @@ from typing import Any, cast from overrides import override +from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings -from .common import RuntimeDependency, RuntimeDependencyCollection +from .common import RuntimeDependency, RuntimeDependencyCollection, is_unreal_engine_project log = logging.getLogger(__name__) @@ -103,10 +105,11 @@ class ClangdLanguageServer(SolidLanguageServer): @override def is_ignored_dirname(self, dirname: str) -> bool: - ignored_dirs = [ - ".ccls-cache", - ] - return super().is_ignored_dirname(dirname) or dirname in ignored_dirs + return ( + super().is_ignored_dirname(dirname) + or dirname == ".ccls-cache" + or (is_unreal_engine_project(self.repository_root_path) and dirname in UE_IGNORED_DIRNAMES) + ) def _prepare_compile_commands(self) -> str | None: """ @@ -128,7 +131,14 @@ class ClangdLanguageServer(SolidLanguageServer): compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json") if not os.path.exists(compile_db_path): - # No compile_commands.json, nothing to do + # clangd can't resolve UE engine headers without the database; fail with guidance. + if is_unreal_engine_project(self.repository_root_path): + raise SolidLSPException( + f"No compile_commands.json found at {self.repository_root_path}, but this looks like an " + "Unreal Engine project (.uproject present). clangd needs a compilation database to resolve " + "engine headers and macros. See docs/03-special-guides/" + "unreal_engine_setup_guide_for_serena.md to generate one." + ) return None try: diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index dc38076d..3cf9dcbf 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import os +import pathlib import platform import shutil import subprocess @@ -216,3 +217,13 @@ def quote_windows_path(path: str) -> str: return path return f'"{path}"' return path + + +UE_IGNORED_DIRNAMES = frozenset({"Binaries", "DerivedDataCache", "Intermediate", "Saved"}) +"""Unreal Engine build and cache directories. Matched per directory name, so per-plugin and +per-module copies (e.g. ``Plugins/Foo/Intermediate``) are covered too.""" + + +def is_unreal_engine_project(repository_root_path: str) -> bool: + """:return: whether the repository root contains an Unreal Engine ``.uproject`` file.""" + return any(pathlib.Path(repository_root_path).glob("*.uproject")) diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h new file mode 100644 index 00000000..dfe4e112 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h @@ -0,0 +1,9 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +// DECLARE_FUNCTION(execOnAbilityInput) +#pragma once + +class AAbilityActor; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp new file mode 100644 index 00000000..f63150c2 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp @@ -0,0 +1,22 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: repeats the hand-written identifiers (as real thunks do) so a +// text search would surface this file; symbol-level tools must not. +// +// void UAbilityComponent::execTriggerAbility(...) { TriggerAbility(AbilityName); } +// void UAbilityComponent::execGetRemainingCooldown(...) { GetRemainingCooldown(AbilityName); } +// void AAbilityActor::execOnAbilityInput(...) { OnAbilityInput(AbilityName); } + +namespace UnrealReflectionStub +{ + const char* GeneratedSymbols[] = { + "UAbilityComponent", + "TriggerAbility", + "GetRemainingCooldown", + "AAbilityActor", + "OnAbilityInput", + "FAbilityInfo", + }; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h new file mode 100644 index 00000000..6b67bfce --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h @@ -0,0 +1,10 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +// DECLARE_FUNCTION(execTriggerAbility) +// DECLARE_FUNCTION(execGetRemainingCooldown) +#pragma once + +class UAbilityComponent; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h new file mode 100644 index 00000000..94f73045 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h @@ -0,0 +1,8 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +#pragma once + +struct FAbilityInfo; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h new file mode 100644 index 00000000..d8925f26 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h @@ -0,0 +1,10 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +// DECLARE_FUNCTION(execReceiveDamage) +#pragma once + +class UDamageable; +class IDamageable; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h new file mode 100644 index 00000000..312e057c --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h @@ -0,0 +1,10 @@ +/*=========================================================================== + Generated code exported from UnrealHeaderTool. + DO NOT modify this manually! Edit the corresponding .h files instead! +===========================================================================*/ +// Test stub: trimmed to what the fixture needs. +// DECLARE_FUNCTION(execHeal) +// DECLARE_FUNCTION(execReceiveDamage) +#pragma once + +class AGameCharacter; diff --git a/test/resources/repos/cpp/ue_test_repo/MyGame.uproject b/test/resources/repos/cpp/ue_test_repo/MyGame.uproject new file mode 100644 index 00000000..c0a6c1fc --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/MyGame.uproject @@ -0,0 +1,4 @@ +{ + "FileVersion": 3, + "EngineAssociation": "5.7" +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp new file mode 100644 index 00000000..8ca27d9c --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp @@ -0,0 +1,18 @@ +#include "AbilityActor.h" + +void AAbilityActor::BeginPlay() +{ + AActor::BeginPlay(); + if (AbilityComponent) + { + AbilityComponent->TriggerAbility(FName("Dash")); + } +} + +void AAbilityActor::OnAbilityInput(const FName& AbilityName) +{ + if (AbilityComponent) + { + AbilityComponent->TriggerAbility(AbilityName); + } +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h new file mode 100644 index 00000000..a728131f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h @@ -0,0 +1,24 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "AbilityComponent.h" +#include "AbilityActor.generated.h" + +UCLASS(Blueprintable) +class TESTGAME_API AAbilityActor : public AActor +{ + GENERATED_BODY() + +public: + virtual void BeginPlay() override; + + UFUNCTION(BlueprintCallable, Category = "Input") + void OnAbilityInput(const FName& AbilityName); + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components") + TObjectPtr<UAbilityComponent> AbilityComponent = nullptr; + + UPROPERTY(EditDefaultsOnly, Category = "Abilities") + TSubclassOf<UAbilityComponent> ComponentClass; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp new file mode 100644 index 00000000..81cab7c1 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp @@ -0,0 +1,16 @@ +#include "AbilityComponent.h" + +void UAbilityComponent::TriggerAbility(const FName& AbilityName) +{ + if (!ActiveCooldowns.Contains(AbilityName)) + { + ActiveCooldowns.Add(AbilityName, 1.0f); + } + State = EAbilityState::Active; + OnAbilityTriggered.Broadcast(AbilityName); +} + +float UAbilityComponent::GetRemainingCooldown(const FName& AbilityName) const +{ + return 0.0f; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h new file mode 100644 index 00000000..0216ac43 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h @@ -0,0 +1,40 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "AbilityTypes.h" +#include "AbilityComponent.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAbilityTriggered, const FName&, AbilityName); +DECLARE_MULTICAST_DELEGATE_OneParam(FOnCooldownExpired, const FName&); + +UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent)) +class TESTGAME_API UAbilityComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = "Abilities") + void TriggerAbility(const FName& AbilityName); + + UFUNCTION(BlueprintPure, Category = "Abilities") + float GetRemainingCooldown(const FName& AbilityName) const; + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Abilities") + TArray<FAbilityInfo> Abilities; + + UPROPERTY(VisibleAnywhere, Category = "Abilities") + TMap<FName, float> ActiveCooldowns; + + UPROPERTY(BlueprintAssignable, Category = "Abilities") + FOnAbilityTriggered OnAbilityTriggered; + + UPROPERTY(EditAnywhere, Category = "Abilities") + EAbilityState State = EAbilityState::Idle; + + UPROPERTY(EditAnywhere, Category = "Abilities") + TSet<FName> UnlockedAbilities; + + // Non-dynamic delegates are not reflected and cannot be UPROPERTYs. + FOnCooldownExpired OnCooldownExpired; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h new file mode 100644 index 00000000..a694e25c --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h @@ -0,0 +1,24 @@ +#pragma once + +#include "CoreMinimal.h" +#include "AbilityTypes.generated.h" + +UENUM(BlueprintType) +enum class EAbilityState : uint8 +{ + Idle, + Active UMETA(DisplayName = "Active (in use)"), + Cooldown, +}; + +USTRUCT(BlueprintType) +struct TESTGAME_API FAbilityInfo +{ + GENERATED_BODY() + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FName AbilityName; + + UPROPERTY(EditAnywhere, BlueprintReadWrite) + float CooldownSeconds = 1.0f; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h new file mode 100644 index 00000000..825c1d89 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h @@ -0,0 +1,20 @@ +#pragma once + +#include "CoreMinimal.h" +#include "UObject/Interface.h" +#include "Damageable.generated.h" + +UINTERFACE(MinimalAPI, Blueprintable) +class UDamageable : public UInterface +{ + GENERATED_BODY() +}; + +class TESTGAME_API IDamageable +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category = "Damage") + virtual void ReceiveDamage(float Amount) = 0; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp new file mode 100644 index 00000000..f50b129d --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp @@ -0,0 +1,29 @@ +#include "GameCharacter.h" +#include "TestGameLog.h" + +AGameCharacter::AGameCharacter() +{ + Abilities = CreateDefaultSubobject<UAbilityComponent>(FName(TEXT("Abilities"))); +} + +void AGameCharacter::ReceiveDamage(float Amount) +{ + check(Amount >= 0.0f); + Health -= Amount; + LastDamageAmount = Amount; + UE_LOG(LogTestGame, Warning, TEXT("Received %f damage"), Amount); + + if (AActor* Target = CurrentTarget.Get()) + { + if (AGameCharacter* OtherCharacter = Cast<AGameCharacter>(Target)) + { + OtherCharacter->Heal(Amount); + } + } +} + +void AGameCharacter::Heal(float& Amount) +{ + Health += Amount; + Amount = 0.0f; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h new file mode 100644 index 00000000..892a42ea --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h @@ -0,0 +1,39 @@ +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Character.h" +#include "AbilityComponent.h" +#include "Damageable.h" +#include "GameCharacter.generated.h" + +UCLASS(Blueprintable) +class TESTGAME_API AGameCharacter : public ACharacter, public IDamageable +{ + GENERATED_BODY() + +public: + AGameCharacter(); + + virtual void ReceiveDamage(float Amount) override; + + UFUNCTION(BlueprintCallable, Category = "Health") + void Heal(UPARAM(ref) float& Amount); + + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawn") + FVector SpawnOffset = FVector(0.0, 0.0, 100.0); + + UPROPERTY(EditAnywhere, Category = "Abilities") + TObjectPtr<UAbilityComponent> Abilities; + + UPROPERTY(VisibleAnywhere, Category = "Targeting") + TWeakObjectPtr<AActor> CurrentTarget; + + UPROPERTY(EditAnywhere, Category = "Loadout") + TSoftObjectPtr<UAbilityComponent> FallbackLoadout; + +private: + // Non-reflected runtime state: smart pointers and optionals are not UPROPERTYs. + TSharedPtr<FAbilityInfo> PendingAbility; + TOptional<float> LastDamageAmount; + float Health = 100.0f; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h new file mode 100644 index 00000000..10caade9 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h @@ -0,0 +1,5 @@ +#pragma once + +#include "CoreMinimal.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogTestGame, Log, All); diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp new file mode 100644 index 00000000..4bd6a5df --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp @@ -0,0 +1,6 @@ +#include "TestGameLog.h" +#include "Modules/ModuleManager.h" + +DEFINE_LOG_CATEGORY(LogTestGame) + +IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, TestGame, "TestGame"); diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h new file mode 100644 index 00000000..b728d771 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h @@ -0,0 +1,10 @@ +#pragma once + +#include "UObject/Object.h" + +class UActorComponent : public UObject +{ +public: + virtual void BeginPlay() {} + virtual void TickComponent(float DeltaTime) {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h new file mode 100644 index 00000000..408385f3 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h @@ -0,0 +1,11 @@ +// Minimal TArray stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template <typename ElementType> +class TArray +{ +public: + void Add(const ElementType& Item) {} + int Num() const { return 0; } + ElementType* GetData() { return nullptr; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h new file mode 100644 index 00000000..10c835f5 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h @@ -0,0 +1,12 @@ +// Minimal TMap stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template <typename KeyType, typename ValueType> +class TMap +{ +public: + ValueType& Add(const KeyType& Key, const ValueType& Value) { static ValueType V; return V; } + ValueType* Find(const KeyType& Key) { return nullptr; } + bool Contains(const KeyType& Key) const { return false; } + int Num() const { return 0; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h new file mode 100644 index 00000000..a30afa36 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h @@ -0,0 +1,11 @@ +// Minimal TSet stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template <typename ElementType> +class TSet +{ +public: + void Add(const ElementType& Item) {} + bool Contains(const ElementType& Item) const { return false; } + int Num() const { return 0; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h new file mode 100644 index 00000000..18211753 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h @@ -0,0 +1,20 @@ +// Stand-in for Unreal's umbrella header. +#pragma once + +#include "CoreTypes.h" +#include "Containers/Array.h" +#include "Containers/Map.h" +#include "Containers/Set.h" +#include "Delegates/DelegateCombinations.h" +#include "Logging/LogMacros.h" +#include "Math/MathFwd.h" +#include "Misc/Optional.h" +#include "Templates/SharedPointer.h" +#include "Templates/SubclassOf.h" +#include "Templates/UniquePtr.h" +#include "UObject/Object.h" +#include "UObject/ObjectMacros.h" +#include "UObject/ObjectPtr.h" +#include "UObject/SoftObjectPtr.h" +#include "UObject/UObjectGlobals.h" +#include "UObject/WeakObjectPtr.h" diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h new file mode 100644 index 00000000..275a2918 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h @@ -0,0 +1,41 @@ +// Minimal stand-ins for core Unreal types and ubiquitous utility macros. +// Real homes in UE: TEXT/FORCEINLINE in Misc/, assertions in Misc/AssertionMacros.h, +// UE_DEPRECATED in Misc/CoreMiscDefines.h; folded together here for stub brevity. +#pragma once + +class FName +{ +public: + FName() = default; + FName(const char* InName) {} + bool operator==(const FName& Other) const { return true; } + bool operator<(const FName& Other) const { return false; } +}; + +class FString +{ +public: + FString() = default; + FString(const char* InStr) {} +}; + +class FText +{ +public: + FText() = default; + static FText FromString(const FString& InString) { return FText(); } +}; + +using int32 = int; +using uint32 = unsigned int; +using uint8 = unsigned char; + +#define TEXT(x) x +#define FORCEINLINE inline +#define UE_DEPRECATED(Version, Message) + +#define check(expr) +#define checkf(expr, format, ...) +#define verify(expr) +#define ensure(expr) (!!(expr)) +#define ensureMsgf(expr, format, ...) (!!(expr)) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h new file mode 100644 index 00000000..5b1fc2b2 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h @@ -0,0 +1,81 @@ +// Stub of UE's delegate declaration macros (Delegates/DelegateCombinations.h). +// Unlike the empty annotation macros, these manufacture a class at the expansion +// site. The stubs preserve exactly that property for every delegate family: +// single-cast, multicast, events, and the dynamic (reflection-visible) variants. +#pragma once + +#define DECLARE_DELEGATE(DelegateName) \ + class DelegateName \ + { \ + public: \ + void Execute() {} \ + bool IsBound() const { return false; } \ + }; + +#define DECLARE_DELEGATE_OneParam(DelegateName, Param1Type) \ + class DelegateName \ + { \ + public: \ + void Execute(Param1Type) {} \ + bool IsBound() const { return false; } \ + }; + +#define DECLARE_DELEGATE_RetVal(ReturnType, DelegateName) \ + class DelegateName \ + { \ + public: \ + ReturnType Execute() { return ReturnType(); } \ + bool IsBound() const { return false; } \ + }; + +#define DECLARE_MULTICAST_DELEGATE(DelegateName) \ + class DelegateName \ + { \ + public: \ + void Broadcast() {} \ + }; + +#define DECLARE_MULTICAST_DELEGATE_OneParam(DelegateName, Param1Type) \ + class DelegateName \ + { \ + public: \ + void Broadcast(Param1Type) {} \ + }; + +#define DECLARE_EVENT(OwningType, EventName) \ + class EventName \ + { \ + public: \ + void Broadcast() {} \ + }; + +#define DECLARE_DYNAMIC_DELEGATE(DelegateName) \ + class DelegateName \ + { \ + public: \ + void ExecuteIfBound() {} \ + }; + +#define DECLARE_DYNAMIC_MULTICAST_DELEGATE(DelegateName) \ + class DelegateName \ + { \ + public: \ + void Broadcast() {} \ + void AddDynamic(void* Object, void* Func) {} \ + }; + +#define DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(DelegateName, Param1Type, Param1Name) \ + class DelegateName \ + { \ + public: \ + void Broadcast(Param1Type Param1Name) {} \ + void AddDynamic(void* Object, void* Func) {} \ + }; + +#define DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(DelegateName, Param1Type, Param1Name, Param2Type, Param2Name) \ + class DelegateName \ + { \ + public: \ + void Broadcast(Param1Type Param1Name, Param2Type Param2Name) {} \ + void AddDynamic(void* Object, void* Func) {} \ + }; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h new file mode 100644 index 00000000..9da8b5dd --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h @@ -0,0 +1,9 @@ +#pragma once + +#include "UObject/Object.h" + +class UWorld : public UObject +{ +public: + float GetTimeSeconds() const { return 0.0f; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h new file mode 100644 index 00000000..80c4536f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "UObject/Object.h" + +class UWorld; + +class AActor : public UObject +{ +public: + virtual void BeginPlay() {} + virtual void Tick(float DeltaSeconds) {} + UWorld* GetWorld() const { return nullptr; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h new file mode 100644 index 00000000..014799ce --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h @@ -0,0 +1,9 @@ +#pragma once + +#include "GameFramework/Pawn.h" + +class ACharacter : public APawn +{ +public: + virtual void Jump() {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h new file mode 100644 index 00000000..4007abc1 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h @@ -0,0 +1,9 @@ +#pragma once + +#include "GameFramework/Actor.h" + +class APawn : public AActor +{ +public: + virtual void PossessedBy(AActor* NewController) {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h new file mode 100644 index 00000000..fd774610 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h @@ -0,0 +1,13 @@ +// Stub of UE's log category macros (Logging/LogMacros.h). The DECLARE/DEFINE pair +// manufactures a category object symbol; UE_LOG itself expands to nothing relevant. +#pragma once + +#define DECLARE_LOG_CATEGORY_EXTERN(CategoryName, DefaultVerbosity, CompileTimeVerbosity) \ + struct FLogCategory##CategoryName \ + { \ + }; \ + extern FLogCategory##CategoryName CategoryName; + +#define DEFINE_LOG_CATEGORY(CategoryName) FLogCategory##CategoryName CategoryName; + +#define UE_LOG(CategoryName, Verbosity, Format, ...) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h new file mode 100644 index 00000000..5c6aede5 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h @@ -0,0 +1,25 @@ +// Minimal stand-ins for UE math types. +#pragma once + +struct FVector +{ + double X = 0.0; + double Y = 0.0; + double Z = 0.0; + + FVector() = default; + FVector(double InX, double InY, double InZ) : X(InX), Y(InY), Z(InZ) {} +}; + +struct FRotator +{ + double Pitch = 0.0; + double Yaw = 0.0; + double Roll = 0.0; +}; + +struct FTransform +{ + FVector GetLocation() const { return FVector(); } + FRotator Rotator() const { return FRotator(); } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h new file mode 100644 index 00000000..a49794bd --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h @@ -0,0 +1,12 @@ +// Minimal TOptional stand-in. +#pragma once + +template <typename T> +class TOptional +{ +public: + TOptional() = default; + TOptional(const T& InValue) {} + bool IsSet() const { return false; } + const T& GetValue() const { static T Value; return Value; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h new file mode 100644 index 00000000..20aa630d --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h @@ -0,0 +1,9 @@ +// Stub of UE's module boilerplate macros (Modules/ModuleManager.h). +#pragma once + +class FDefaultGameModuleImpl +{ +}; + +#define IMPLEMENT_PRIMARY_GAME_MODULE(ModuleImplClass, ModuleName, GameName) +#define IMPLEMENT_MODULE(ModuleImplClass, ModuleName) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h new file mode 100644 index 00000000..3eaf59a7 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h @@ -0,0 +1,31 @@ +// Minimal stand-ins for UE's non-UObject smart pointers. +#pragma once + +template <typename T> +class TSharedPtr +{ +public: + TSharedPtr() = default; + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template <typename T> +class TSharedRef +{ +public: + T& Get() const { static T Instance; return Instance; } +}; + +template <typename T> +class TWeakPtr +{ +public: + TSharedPtr<T> Pin() const { return TSharedPtr<T>(); } +}; + +template <typename T, typename... ArgTypes> +TSharedRef<T> MakeShared(ArgTypes&&... Args) +{ + return TSharedRef<T>(); +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h new file mode 100644 index 00000000..f7b84e17 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h @@ -0,0 +1,9 @@ +// Minimal TSubclassOf stand-in. +#pragma once + +template <typename T> +class TSubclassOf +{ +public: + TSubclassOf() = default; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h new file mode 100644 index 00000000..c86daf74 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h @@ -0,0 +1,17 @@ +// Minimal TUniquePtr stand-in. +#pragma once + +template <typename T> +class TUniquePtr +{ +public: + TUniquePtr() = default; + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template <typename T, typename... ArgTypes> +TUniquePtr<T> MakeUnique(ArgTypes&&... Args) +{ + return TUniquePtr<T>(); +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h new file mode 100644 index 00000000..091afce6 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h @@ -0,0 +1,8 @@ +#pragma once + +#include "UObject/Object.h" + +// Base class for the U-side of UE's interface pattern (UINTERFACE). +class UInterface : public UObject +{ +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h new file mode 100644 index 00000000..ba30f34d --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h @@ -0,0 +1,15 @@ +#pragma once + +#include "CoreTypes.h" + +class UObject +{ +public: + virtual ~UObject() = default; + + template <typename T> + T* CreateDefaultSubobject(const FName& SubobjectName) + { + return nullptr; + } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h new file mode 100644 index 00000000..56a46b16 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h @@ -0,0 +1,20 @@ +// Stub mirroring Unreal Engine 5.7 ObjectMacros.h (Engine/Source/Runtime/CoreUObject/ +// Public/UObject/ObjectMacros.h, lines 744-776). The annotation macros below are +// faithful: in real UE compilation they always expand to nothing (only UnrealHeaderTool +// parses their arguments). GENERATED_BODY is simplified: the real macro pastes +// declarations from the per-class *.generated.h via __LINE__ token concatenation. +#pragma once + +#define UPROPERTY(...) +#define UFUNCTION(...) +#define UPARAM(...) +#define USTRUCT(...) +#define UMETA(...) +#define UENUM(...) +#define UCLASS(...) +#define UINTERFACE(...) +#define UDELEGATE(...) + +#define GENERATED_BODY(...) public: +#define GENERATED_USTRUCT_BODY(...) public: +#define GENERATED_UCLASS_BODY(...) public: diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h new file mode 100644 index 00000000..e6b15032 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h @@ -0,0 +1,13 @@ +// Minimal TObjectPtr stand-in (UE5's idiomatic UPROPERTY pointer wrapper). +#pragma once + +template <typename T> +class TObjectPtr +{ +public: + TObjectPtr() = default; + TObjectPtr(T* InPtr) {} + T* Get() const { return nullptr; } + T* operator->() const { return nullptr; } + explicit operator bool() const { return false; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h new file mode 100644 index 00000000..609443d1 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h @@ -0,0 +1,18 @@ +// Minimal stand-ins for UE's soft (lazy-loadable) object references. +#pragma once + +template <typename T> +class TSoftObjectPtr +{ +public: + TSoftObjectPtr() = default; + T* LoadSynchronous() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template <typename T> +class TSoftClassPtr +{ +public: + TSoftClassPtr() = default; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h new file mode 100644 index 00000000..4a86f20b --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h @@ -0,0 +1,16 @@ +// Minimal stand-ins for UObject global helpers (Cast, NewObject). +#pragma once + +class UObject; + +template <typename T> +T* Cast(UObject* Object) +{ + return nullptr; +} + +template <typename T> +T* NewObject(UObject* Outer = nullptr) +{ + return nullptr; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h new file mode 100644 index 00000000..e45451aa --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h @@ -0,0 +1,12 @@ +// Minimal TWeakObjectPtr stand-in. +#pragma once + +template <typename T> +class TWeakObjectPtr +{ +public: + TWeakObjectPtr() = default; + TWeakObjectPtr(T* InPtr) {} + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/compile_commands.json b/test/resources/repos/cpp/ue_test_repo/compile_commands.json new file mode 100644 index 00000000..3009849e --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/compile_commands.json @@ -0,0 +1,22 @@ +[ + { + "directory": ".", + "command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/AbilityComponent.cpp", + "file": "Source/TestGame/AbilityComponent.cpp" + }, + { + "directory": ".", + "command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/AbilityActor.cpp", + "file": "Source/TestGame/AbilityActor.cpp" + }, + { + "directory": ".", + "command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/GameCharacter.cpp", + "file": "Source/TestGame/GameCharacter.cpp" + }, + { + "directory": ".", + "command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/TestGameModule.cpp", + "file": "Source/TestGame/TestGameModule.cpp" + } +] diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py index de894f13..79c04617 100644 --- a/test/solidlsp/conftest.py +++ b/test/solidlsp/conftest.py @@ -23,6 +23,40 @@ def is_diagnostics_test_file(relative_path: str) -> bool: return filename.startswith(("diagnosticssample.", "diagnostics_sample.")) +def document_symbol_names(language_server: SolidLanguageServer, relative_path: str) -> list[str]: + """All symbol names in a file's document-symbol tree, including children.""" + symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + names: list[str] = [] + + def _collect(syms) -> None: + for sym in syms: + names.append(sym.get("name")) + _collect(sym.get("children", []) or []) + + _collect(symbol_list) + return names + + +def find_document_symbol(language_server: SolidLanguageServer, relative_path: str, name: str) -> UnifiedSymbolInformation: + """The first symbol called ``name`` in a file's document-symbol tree; fails the test if absent.""" + symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots() + symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols + + def _search(syms): + for sym in syms: + if sym.get("name") == name: + return sym + found = _search(sym.get("children", []) or []) + if found is not None: + return found + return None + + result = _search(symbol_list) + assert result is not None, f"Symbol '{name}' not found in {relative_path}" + return result + + def has_malformed_name( symbol: UnifiedSymbolInformation, whitespace_allowed: bool = False, diff --git a/test/solidlsp/cpp/test_cpp_unreal.py b/test/solidlsp/cpp/test_cpp_unreal.py new file mode 100644 index 00000000..540becd0 --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_unreal.py @@ -0,0 +1,187 @@ +""" +Unreal Engine 5 fixture tests for the clangd language server. + +UE game code is written against a macro-based reflection layer (UCLASS, UFUNCTION, +UPROPERTY, GENERATED_BODY) and engine container types (TArray, TMap). These tests +verify that the clangd backend resolves symbols, references, definitions, and +rename edits in the hand-written sources under Source/, and never in the +UnrealHeaderTool-style generated files under Intermediate/ (which contain the +same identifiers, as real generated reflection code does). + +The fixture's stub engine headers mirror UE 5.7's ObjectMacros.h: annotation +macros are empty in real UE compilation too (only UnrealHeaderTool parses them). + +The fixture lives in its own repository directory (ue_test_repo) served by +clangd only: ccls 0.20240202, the build shipped by Ubuntu and Homebrew, crashes +intermittently when its session covers the stub engine headers, and clangd is +the supported backend for Unreal Engine projects (see the setup guide). +""" + +import os +from collections.abc import Iterator + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import find_identifier_position, get_repo_path, start_ls_context +from test.solidlsp.conftest import document_symbol_names, find_document_symbol + +UE_REPO_PATH = get_repo_path(Language.CPP).parent / "ue_test_repo" +ABILITY_COMPONENT_H = os.path.join("Source", "TestGame", "AbilityComponent.h") +ABILITY_ACTOR_H = os.path.join("Source", "TestGame", "AbilityActor.h") +ABILITY_ACTOR_CPP = os.path.join("Source", "TestGame", "AbilityActor.cpp") + + +@pytest.fixture(scope="module") +def language_server() -> Iterator[SolidLanguageServer]: + """Clangd over ue_test_repo; overrides the shared fixture for this module.""" + with start_ls_context(Language.CPP, repo_path=str(UE_REPO_PATH)) as ls: + yield ls + + +@pytest.mark.cpp +class TestClangdUnrealEngine: + """clangd on Unreal Engine-shaped C++ (reflection macros, engine containers).""" + + def test_symbol_tree_contains_reflected_types(self, language_server: SolidLanguageServer) -> None: + """UCLASS/USTRUCT-decorated types appear in the full symbol tree.""" + symbols = language_server.request_full_symbol_tree() + for name in ("UAbilityComponent", "AAbilityActor", "FAbilityInfo"): + assert SymbolUtils.symbol_tree_contains_name(symbols, name), f"'{name}' not found in symbol tree" + + def test_document_symbols_show_uclass_members(self, language_server: SolidLanguageServer) -> None: + """UFUNCTION methods and UPROPERTY fields are visible despite the macro layer.""" + names = document_symbol_names(language_server, ABILITY_COMPONENT_H) + for expected in ("UAbilityComponent", "TriggerAbility", "GetRemainingCooldown", "Abilities", "ActiveCooldowns"): + assert expected in names, f"Expected '{expected}' in document symbols of AbilityComponent.h, got: {names}" + + def test_references_resolve_to_handwritten_sources(self, language_server: SolidLanguageServer) -> None: + """References to a UFUNCTION are found across files, and only in Source/, never Intermediate/.""" + # Precondition: the identifier appears verbatim in generated files on disk. + # A text search would return them; a symbol-level tool must not. + gen_cpp = UE_REPO_PATH / "Intermediate" / "TestGame" / "UHT" / "AbilityComponent.gen.cpp" + assert "TriggerAbility" in gen_cpp.read_text(encoding="utf-8"), "fixture broken: honeypot lost its bait" + + trigger = find_document_symbol(language_server, ABILITY_COMPONENT_H, "TriggerAbility") + sel_start = trigger["selectionRange"]["start"] + refs = language_server.request_references(ABILITY_COMPONENT_H, sel_start["line"], sel_start["character"]) + ref_files = [ref.get("relativePath", "") for ref in refs] + assert any("AbilityActor.cpp" in ref_file for ref_file in ref_files), ( + f"Expected cross-file reference in AbilityActor.cpp, got: {ref_files}" + ) + leaked = [ref_file for ref_file in ref_files if "Intermediate" in ref_file] + assert not leaked, f"References leaked into generated files: {leaked}" + + def test_definition_resolves_to_source_not_generated(self, language_server: SolidLanguageServer) -> None: + """Go-to-definition on a UCLASS usage lands in the hand-written header.""" + header_path = UE_REPO_PATH / "Source" / "TestGame" / "AbilityActor.h" + position = find_identifier_position(header_path, "UAbilityComponent") + assert position is not None, "UAbilityComponent is not used in AbilityActor.h" + definitions = language_server.request_definition(ABILITY_ACTOR_H, position[0], position[1]) + def_paths = [d.get("relativePath", "") for d in definitions] + assert any("AbilityComponent.h" in p for p in def_paths), f"Expected definition in AbilityComponent.h, got: {def_paths}" + assert all("Intermediate" not in p for p in def_paths), f"Definition resolved into generated files: {def_paths}" + + def test_symbol_locations_are_in_handwritten_sources(self, language_server: SolidLanguageServer) -> None: + """Each reflected symbol's defining declaration is locatable in Source/. + Serena's symbol edits (replace_symbol_body, insert_after_symbol) operate at + these locations, so this is the edit-targeting guarantee. Generated headers + may legitimately surface same-named forward declarations (real UHT output + contains them); what matters is that the Source/ declaration is present. + """ + expected_files = { + "UAbilityComponent": "AbilityComponent.h", + "AAbilityActor": "AbilityActor.h", + "FAbilityInfo": "AbilityTypes.h", + "TriggerAbility": "AbilityComponent.h", + "OnAbilityInput": "AbilityActor.h", + } + symbols = language_server.request_full_symbol_tree() + found: dict[str, list[str]] = {name: [] for name in expected_files} + + def _walk(syms): + for sym in syms: + name = sym.get("name") + if name in found: + location = sym.get("location") or {} + found[name].append(location.get("relativePath", "") or str(location.get("uri", ""))) + _walk(sym.get("children", []) or []) + + _walk(symbols) + for name, expected_file in expected_files.items(): + source_locations = [p for p in found[name] if "Source" in p and expected_file in p] + assert source_locations, f"'{name}' has no location in Source/{{...}}/{expected_file}, got: {found[name]}" + + def test_uenum_and_enumerators_visible(self, language_server: SolidLanguageServer) -> None: + """UENUM-decorated enum and its UMETA-annotated enumerators appear as symbols.""" + ability_types_h = os.path.join("Source", "TestGame", "AbilityTypes.h") + names = document_symbol_names(language_server, ability_types_h) + for expected in ("EAbilityState", "Idle", "Active", "Cooldown"): + assert expected in names, f"Expected '{expected}' in document symbols of AbilityTypes.h, got: {names}" + + def test_macro_generated_delegate_type_resolves_to_source(self, language_server: SolidLanguageServer) -> None: + """DECLARE_DYNAMIC_MULTICAST_DELEGATE manufactures a class, which must appear + as a symbol located at the macro expansion site in the hand-written header. + """ + delegate = find_document_symbol(language_server, ABILITY_COMPONENT_H, "FOnAbilityTriggered") + location_path = (delegate.get("location") or {}).get("relativePath", "") + assert location_path, f"FOnAbilityTriggered has no location: {delegate}" + assert "Intermediate" not in location_path, f"Delegate symbol located in generated files: {location_path}" + member_names = document_symbol_names(language_server, ABILITY_COMPONENT_H) + assert "OnAbilityTriggered" in member_names, "BlueprintAssignable delegate property not visible" + + def test_interface_pattern_symbols(self, language_server: SolidLanguageServer) -> None: + """The UINTERFACE pattern (paired UDamageable/IDamageable classes) yields both + classes and the interface method; the implementing class shows the override. + """ + damageable_h = os.path.join("Source", "TestGame", "Damageable.h") + names = document_symbol_names(language_server, damageable_h) + for expected in ("UDamageable", "IDamageable", "ReceiveDamage"): + assert expected in names, f"Expected '{expected}' in document symbols of Damageable.h, got: {names}" + + character_h = os.path.join("Source", "TestGame", "GameCharacter.h") + character_names = document_symbol_names(language_server, character_h) + assert "ReceiveDamage" in character_names, "Interface override not visible in implementing class" + + def test_log_category_symbol_in_source(self, language_server: SolidLanguageServer) -> None: + """DECLARE_LOG_CATEGORY_EXTERN manufactures a category object symbol in hand-written code.""" + log_h = os.path.join("Source", "TestGame", "TestGameLog.h") + names = document_symbol_names(language_server, log_h) + assert "LogTestGame" in names, f"Expected log category symbol 'LogTestGame', got: {names}" + + def test_nondynamic_delegate_type_in_source(self, language_server: SolidLanguageServer) -> None: + """DECLARE_MULTICAST_DELEGATE (non-dynamic family) also manufactures a type in Source/.""" + delegate = find_document_symbol(language_server, ABILITY_COMPONENT_H, "FOnCooldownExpired") + location_path = (delegate.get("location") or {}).get("relativePath", "") + assert location_path, f"FOnCooldownExpired has no location: {delegate}" + assert "Intermediate" not in location_path, f"Delegate symbol located in generated files: {location_path}" + + def test_character_members_with_ue_types_visible(self, language_server: SolidLanguageServer) -> None: + """Members typed with FVector/TObjectPtr/TWeakObjectPtr/TSoftObjectPtr and a + UPARAM(ref) UFUNCTION are all visible in the symbol tree. + """ + character_h = os.path.join("Source", "TestGame", "GameCharacter.h") + names = document_symbol_names(language_server, character_h) + for expected in ("AGameCharacter", "SpawnOffset", "Abilities", "CurrentTarget", "FallbackLoadout", "Heal"): + assert expected in names, f"Expected '{expected}' in document symbols of GameCharacter.h, got: {names}" + + def test_rename_edit_targets_only_source_files(self, language_server: SolidLanguageServer) -> None: + """A rename WorkspaceEdit for a UFUNCTION touches only hand-written files (edit is not applied).""" + trigger = find_document_symbol(language_server, ABILITY_COMPONENT_H, "TriggerAbility") + sel_start = trigger["selectionRange"]["start"] + edit = language_server.request_rename_symbol_edit(ABILITY_COMPONENT_H, sel_start["line"], sel_start["character"], "ActivateAbility") + assert edit is not None, "clangd should support rename" + + touched: list[str] = [] + for uri in edit.get("changes") or {}: + touched.append(uri) + for doc_change in edit.get("documentChanges") or []: + text_doc = doc_change.get("textDocument") or {} + if text_doc.get("uri"): + touched.append(text_doc["uri"]) + + assert any("AbilityComponent.h" in uri for uri in touched), f"Rename does not edit the declaring header, touched: {touched}" + leaked = [uri for uri in touched if "Intermediate" in uri] + assert not leaked, f"Rename would edit generated files: {leaked}" diff --git a/test/solidlsp/cpp/test_cpp_unreal_unit.py b/test/solidlsp/cpp/test_cpp_unreal_unit.py new file mode 100644 index 00000000..68d18e6c --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_unreal_unit.py @@ -0,0 +1,60 @@ +"""Unreal Engine handling in the clangd and ccls servers, without starting a process. + +No ``cpp`` marker: these build server shells with ``object.__new__`` and call the no-database +and directory-ignore code paths directly, so they run in the default suite. +""" + +import pytest + +from solidlsp.language_servers.ccls_language_server import CCLS +from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer +from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES, is_unreal_engine_project +from solidlsp.ls_exceptions import SolidLSPException + + +def _make_clangd(root) -> ClangdLanguageServer: + server = object.__new__(ClangdLanguageServer) + server.repository_root_path = str(root) + return server + + +def _make_ccls(root) -> CCLS: + server = object.__new__(CCLS) + server.repository_root_path = str(root) + return server + + +def test_is_unreal_engine_project_detection(tmp_path): + assert is_unreal_engine_project(str(tmp_path)) is False + (tmp_path / "MyGame.uproject").write_text("{}") + assert is_unreal_engine_project(str(tmp_path)) is True + + +def test_missing_compile_commands_raises_for_unreal_project(tmp_path): + (tmp_path / "MyGame.uproject").write_text("{}") + server = _make_clangd(tmp_path) + + with pytest.raises(SolidLSPException, match="Unreal Engine"): + server._prepare_compile_commands() + + +def test_missing_compile_commands_returns_none_for_non_unreal_project(tmp_path): + server = _make_clangd(tmp_path) + assert server._prepare_compile_commands() is None + + +@pytest.mark.parametrize("make_server", [_make_clangd, _make_ccls]) +def test_unreal_dirs_ignored_only_for_unreal_project(make_server, tmp_path): + non_ue = make_server(tmp_path) + for dirname in UE_IGNORED_DIRNAMES: + assert not non_ue.is_ignored_dirname(dirname), f"{dirname} should not be pruned without a .uproject" + + ue_root = tmp_path / "ue" + ue_root.mkdir() + (ue_root / "MyGame.uproject").write_text("{}") + ue = make_server(ue_root) + for dirname in UE_IGNORED_DIRNAMES: + assert ue.is_ignored_dirname(dirname), f"{dirname} should be pruned for a UE project" + + assert non_ue.is_ignored_dirname(".ccls-cache") + assert ue.is_ignored_dirname(".ccls-cache") From ff3c77d4522e5c015866dfef3d7d4cc4ae05bc58 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 25 Jun 2026 13:24:24 +0200 Subject: [PATCH 028/170] Project.validate_relative_path: Report project root in error message --- src/serena/project.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/serena/project.py b/src/serena/project.py index 2c1558a8..f83a881d 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -285,17 +285,15 @@ class Project(ToStringMixin): def validate_relative_path(self, relative_path: str, require_not_ignored: bool = False) -> None: """ - Validates that the given relative path is safe to read or edit, - meaning it's inside the project directory and not ignored. - - Non-existent paths are allowed (not considered ignored) to support - editing tools that create new files. + Validates that the given relative path is within the project directory + (and, optionally, not ignored according to the project's ignore settings), + raising a ValueError if the validation fails. :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 """ if not self.is_path_in_project(relative_path): - raise ValueError(f"{relative_path=} points to path outside of the repository root; cannot access for safety reasons") + raise ValueError(f"{relative_path=} points outside the project root ({self.project_root})") if require_not_ignored: if self.is_ignored_path(relative_path): From a4aa72e19829953f1fc3b8360f70c86eaf40ab19 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Thu, 25 Jun 2026 13:43:33 +0200 Subject: [PATCH 029/170] Hooks: handle tool_input passed as string gracefully instead of failing Copilot CLI sends strings --- CHANGELOG.md | 3 +++ src/serena/hooks.py | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7087b497..a9c71a00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,9 @@ Status of the `main` branch. Changes prior to the next official version change w - Tray manager: Add NixOS-support for AppIndicator-based trays (e.g., most Wayland-trays) to the package in flake.nix. - Fix: Wait for the subprocess that opens the browser window, preventing zombie processes #1488 +* Hooks: + - Handle tool_input passed as string gracefully instead of failing (Copilot CLI sends strings). + Dependencies: - Add dependency `oslex` diff --git a/src/serena/hooks.py b/src/serena/hooks.py index ef5e2106..83a0a8cc 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -70,7 +70,11 @@ class PreToolUseHook(Hook, ABC): if not _tool_name: raise ValueError("Tool name is required in the hook input data") self._tool_name = _tool_name - self._tool_input: dict | None = self._input_data.get("tool_input") or self._input_data.get("toolInput") + raw_tool_input = self._input_data.get("tool_input") or self._input_data.get("toolInput") + # TODO: some agents, like copilot CLI, can send a string as value for raw_tool_input + # Example: "tool_input":"*** Begin Patch\n*** Add File: /Users/acbdef/.copilot/session-state/08a961db-02f0-4c7c-b783-1e9818290292/files/hook-tool-test-3.txt\n+third edit tool test\n*** End Patch\n" + # We currently don't parse such tool input and hence don't react to it in hooks + self._tool_input: dict | None = raw_tool_input if isinstance(raw_tool_input, dict) else None # only relevant in claude code at the moment, (not all events include this field; default to empty string) raw_permission_mode = self._input_data.get("permission_mode") or self._input_data.get("permissionMode") or "" From 4329cdd981adf93dd911c92cc30546c5537bb7c5 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 25 Jun 2026 23:06:49 +0200 Subject: [PATCH 030/170] Refactoring of language backend initialisation --- src/serena/agent.py | 46 +++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 1160b737..b5b727aa 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -1201,16 +1201,35 @@ class SerenaAgent: if update_active_tools: self._update_active_tools() - def init_language_server_manager() -> None: - # start the language server + def init_project_services() -> None: + self._init_active_project_language_backend() + + # initialise the project's language backend in the background + self.issue_task(init_project_services) + + if self._project_activation_callback is not None: + self._project_activation_callback() + + # notify the dashboard manager of the project change (if applicable) + if self._dashboard_manager: + self._dashboard_manager.update_active_project(self._active_project) + + return True + + def _init_active_project_language_backend(self) -> None: + """ + Initialises the active project's language backend + """ + project = self._active_project + assert project is not None + + # for LSP mode, start the language server manager + if self.get_language_backend().is_lsp(): with LogTime("Language server initialization", logger=log): self.reset_language_server_manager() - # initialize the language server in the background (if in language server mode) - if self.get_language_backend().is_lsp(): - self.issue_task(init_language_server_manager) - - def init_jetbrains_ide() -> None: + # for JetBrains mode, search for plugin server and spawn IDE (if not found and launch command provided) + elif self.get_language_backend().is_jetbrains(): try: client = jetbrains_plugin_client.JetBrainsPluginClient.from_project(project, log_warning=False) log.info("Found Serena JetBrains Plugin server: %s", client) @@ -1224,19 +1243,6 @@ class SerenaAgent: if p.returncode != 0: log.error(f"Failed to launch JetBrains IDE: {stderr.decode('utf-8')}") - # for JetBrains mode, search for plugin server and spawn IDE if not found and enabled - if self.get_language_backend().is_jetbrains(): - self.issue_task(init_jetbrains_ide) - - if self._project_activation_callback is not None: - self._project_activation_callback() - - # notify the dashboard manager of the project change (if applicable) - if self._dashboard_manager: - self._dashboard_manager.update_active_project(self._active_project) - - return True - def activate_project_from_path_or_name( self, project_root_or_name: str, update_active_modes: bool = True, update_active_tools: bool = True ) -> bool: From d7a202665635e11a6982b9efe41ef29ba9cf023d Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 26 Jun 2026 10:49:09 +0200 Subject: [PATCH 031/170] Add notion of trusted projects #1608 --- CHANGELOG.md | 2 ++ docs/02-usage/050_configuration.md | 3 ++- src/serena/config/serena_config.py | 24 +++++++++++++++++++ src/serena/project.py | 8 +++++++ .../resources/serena_config.template.yml | 15 ++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9c71a00..9f2b13f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: + - Add notion of trusted projects via new global configuration setting `trusted_project_path_patterns`. + Existing users are advised to edit this setting. - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` now walks up in a single pass so the nearest project boundary wins (either a `.serena/project.yml` or a `.git`, including worktree/submodule pointer files), instead of preferring an ancestor's diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 983924bb..b2e2978b 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -27,10 +27,11 @@ Some of the configurable settings include: this can also be [overridden per project](per-project-language-backend) * UI settings affecting the [Serena Dashboard and GUI tool](060_dashboard.md) * the set of tools to enable/disable by default - * the set of modes to use by default + * the set of [modes](modes) to use by default * tool execution parameters (timeout, max. answer length) * global ignore rules * logging settings + * the set of trusted project paths * advanced settings specific to individual language servers (see [below](ls-specific-settings)) The global configuration settings apply to all projects. diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 4aaabdec..0b8fc673 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -32,6 +32,7 @@ from serena.constants import ( SERENA_MANAGED_DIR_NAME, ) from serena.util.inspection import determine_programming_language_composition +from serena.util.text_utils import glob_match from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml_comments, save_yaml, transfer_yaml_comments from solidlsp.ls_config import Language @@ -785,7 +786,16 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): - "/projects-metadata/$projectFolderName/.serena" (stores data in a central location) """ + trusted_project_path_patterns: list[str] = field(default_factory=lambda: ["**"]) + """ + list of glob patterns for project root directories that are considered trusted. + The default "**" considers all project roots as trusted, which is necessary for backward compatibility. + The default will apply if a user does not yet have the setting, while new users will get the value + defined in the configuration template file. + """ + # settings with overridden defaults + language_backend: LanguageBackend = LanguageBackend.LSP """ the language backend to use for code understanding features @@ -799,6 +809,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): If the budget is exceeded, Serena stops issuing further requests and returns partial info results. 0 disables the budget (no early stopping). Negative values are invalid. """ + # *** fields that are NOT mapped to/from the configuration file *** _loaded_commented_yaml: CommentedMap | None = None @@ -1273,3 +1284,16 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): from serena.tools import JetBrainsPluginClient JetBrainsPluginClient.set_server_address(self.jetbrains_plugin_server_address) + + def is_trusted_project_path(self, project_root: str | Path) -> bool: + """ + Checks if the given project root path matches any of the trusted project root patterns. + + :param project_root: the path to the project root directory + :return: True if the project root is trusted, False otherwise + """ + project_root_str = str(project_root) + for pattern in self.trusted_project_path_patterns: + if glob_match(pattern, project_root_str): + return True + return False diff --git a/src/serena/project.py b/src/serena/project.py index f83a881d..de7c7ea2 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -146,6 +146,14 @@ class Project(ToStringMixin): def path_to_project_yml(self) -> str: return self.serena_config.get_project_yml_location(self.project_root) + def is_trusted(self) -> bool: + """ + Checks whether the project is trusted, based on the global configuration. + + :return: True if the project is trusted, False otherwise + """ + return self.serena_config.is_trusted_project_path(self.project_root) + def read_file(self, relative_path: str) -> str: """ Reads a file relative to the project root. diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index fb5c1d40..efa572ba 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -183,5 +183,20 @@ symbol_info_budget: 10 # Example for a central location: "/projects-metadata/$projectFolderName/.serena" project_serena_folder_location: "$projectDir/.serena" +# list of glob patterns for project root directories that are considered trusted. +# Some project settings will only be applied if the project is trusted. +# A glob pattern can contain the following: +# * matches any sequence of characters except path separators +# ** matches any sequence of characters including path separators +# ? matches any single character except path separators +# [abc] matches any single character in the set (here: a, b, or c) +# Path separators are normalised internally, so on Windows, both / and \ can be used in the patterns. +# Example: +# trusted_project_path_patterns: +# - /home/user/projects/** +# - C:\Users\Anna\Dev\work\** +# The pattern "**" matches any project path, so it can be used to trust all projects. +trusted_project_path_patterns: [] + # the list of registered project paths (updated automatically). projects: [] From 5a867ff3fe12cda528fb7e9ac5c388427b03a7e4 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 26 Jun 2026 12:58:18 +0200 Subject: [PATCH 032/170] Consider ls_specific_settings in project configurations only for trusted projects --- CHANGELOG.md | 3 ++- docs/02-usage/050_configuration.md | 7 +++++-- src/serena/project.py | 10 +++++++++- src/serena/resources/project.template.yml | 4 ++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f2b13f7..1ef1490e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ Status of the `main` branch. Changes prior to the next official version change w * General: - Add notion of trusted projects via new global configuration setting `trusted_project_path_patterns`. - Existing users are advised to edit this setting. + Current effects: + - `ls_specific_settings` defined in project configurations will only be applied for trusted projects - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` now walks up in a single pass so the nearest project boundary wins (either a `.serena/project.yml` or a `.git`, including worktree/submodule pointer files), instead of preferring an ancestor's diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index b2e2978b..8aa12542 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -200,9 +200,12 @@ Most users will not need to adjust these settings. ::: Under the key `ls_specific_settings` in `serena_config.yml`, you can you pass global per-language, -language server-specific configuration. You can use the same key in the project configuration files (`project.yml` +language server-specific configuration. + +You can use the same key in the project configuration files (`project.yml` and `project.local.yml` ) to override or extend the global settings for a specific project. -The settings are merged on top-level, meaning that project-level settings for a language will replace global settings for the same language. +The settings are merged on top-level, meaning that project-level settings for a language will replace global settings for the same language. +Note: Project-level settings are considered only for *trusted projects* (which are defined in the [global configuration](global-config)). Structure: diff --git a/src/serena/project.py b/src/serena/project.py index de7c7ea2..1ddc53ac 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -425,7 +425,15 @@ class Project(ToStringMixin): log.info(f"Creating language server manager for {self.project_root}") self._language_server_manager_init_error = None - ls_specific_settings = {**self.serena_config.ls_specific_settings, **self.project_config.ls_specific_settings} + ls_specific_settings = dict(self.serena_config.ls_specific_settings) + if self.project_config.ls_specific_settings: + if self.is_trusted(): + ls_specific_settings.update(self.project_config.ls_specific_settings) + else: + log.warning( + f"Project path {self.project_root} is not trusted, ignoring LS-specific settings from project configuration. " + "To trust the project, modify the trusted path patterns in the global configuration." + ) factory = LanguageServerFactory( project_root=self.project_root, project_data_path=self._serena_data_folder, diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index a4dddb37..a4dc26ee 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -54,8 +54,8 @@ ignore_all_files_in_gitignore: true # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} # list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). From 011edec4458901062578c0b05d187a49a430bd93 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 13 Apr 2026 13:53:41 +0200 Subject: [PATCH 033/170] Allow to configure structured tool output on a per-context basis * Default is None (behaviour unchanged) * Override is applied for Claude Code, which does not unpack structured output, causing the LLM to see unnecessary escapings in returned tool results #1042 --- CHANGELOG.md | 2 ++ src/serena/config/context_mode.py | 5 ++++ src/serena/mcp.py | 26 +++++++++++++------ .../resources/config/contexts/claude-code.yml | 4 +++ .../config/contexts/context.template.yml | 5 +++- src/serena/tools/tools_base.py | 8 +++--- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7087b497..2fddc9e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Make tool call errors surface explicitly as errors at the MCP protocol level - Fix: a registered project whose root directory was deleted while Serena was already running could break `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` + - Allow structured tool output to be configured on a per-context basis, disabling it for Claude Code + (which does not correctly unpack structured output) #1042 * Language Servers: - C/C++ (clangd): add Unreal Engine 5 fixture and tests verifying that reflection-macro diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 5d42d650..16ef293a 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -184,6 +184,11 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): The `activate_project` tool will, therefore, be disabled in this case, as project switching is not allowed. """ + structured_tool_output: bool | None = None + """ + whether to use structured output for tools (None = auto) + """ + def _tostring_includes(self) -> list[str]: return ["name"] diff --git a/src/serena/mcp.py b/src/serena/mcp.py index a6cf527c..033533c1 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -51,15 +51,16 @@ class SerenaMCPRequestContext: class SerenaFastMCPTool(FastMCPTool): - def __init__(self, tool: Tool, openai_tool_compatible: bool): + def __init__(self, tool: Tool, openai_tool_compatible: bool, structured_output: bool | None): """ :param tool: the Serena tool :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools (doesn't accept integer, needs number instead, etc.). This allows using Serena MCP within Codex. + :param structured_output: whether to use structured output for the tool (None = auto) """ func_name = tool.get_name() func_doc = tool.get_apply_docstring() or "" - func_arg_metadata = tool.get_apply_fn_metadata() + func_arg_metadata = tool.get_apply_fn_metadata(structured_output=structured_output) is_async = False parameters = func_arg_metadata.arg_model.model_json_schema() if openai_tool_compatible: @@ -275,27 +276,34 @@ class SerenaMCPFactory: return walk(s) @staticmethod - def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True) -> SerenaFastMCPTool: + def make_mcp_tool(tool: Tool, openai_tool_compatible: bool = True, structured_output: bool | None = None) -> SerenaFastMCPTool: """ Creates an MCP tool from a Serena Tool instance. :param tool: the Serena Tool instance to convert. :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools (doesn't accept integer, needs number instead, etc.). This allows using Serena MCP within codex. + :param structured_output: whether to use structured output for the tool (None = auto) """ - return SerenaFastMCPTool(tool, openai_tool_compatible) + return SerenaFastMCPTool(tool, openai_tool_compatible=openai_tool_compatible, structured_output=structured_output) def _iter_tools(self) -> Iterator[Tool]: assert self.agent is not None yield from self.agent.get_exposed_tool_instances() # noinspection PyProtectedMember - def _set_mcp_tools(self, mcp: FastMCP, openai_tool_compatible: bool = False) -> None: - """Update the tools in the MCP server""" + def _set_mcp_tools(self, mcp: FastMCP, openai_tool_compatible: bool, structured_output: bool | None) -> None: + """ + Update the tools in the MCP server + + :param mcp: The MCP server to update + :param openai_tool_compatible: whether to process the tool schema to be compatible with OpenAI tools + :param structured_output: whether to use structured output for the tools (None = auto) + """ if mcp is not None: mcp._tool_manager._tools = {} for tool in self._iter_tools(): - mcp_tool = self.make_mcp_tool(tool, openai_tool_compatible=openai_tool_compatible) + mcp_tool = self.make_mcp_tool(tool, openai_tool_compatible=openai_tool_compatible, structured_output=structured_output) mcp._tool_manager._tools[tool.get_name()] = mcp_tool log.info(f"Starting MCP server with {len(mcp._tool_manager._tools)} tools: {list(mcp._tool_manager._tools.keys())}") @@ -389,7 +397,9 @@ class SerenaMCPFactory: :param mcp_server: the MCP server instance to configure """ openai_tool_compatible = self.context.name in ["chatgpt", "codex", "oaicompat-agent"] - self._set_mcp_tools(mcp_server, openai_tool_compatible=openai_tool_compatible) + assert self.agent is not None + context = self.agent.get_context() + self._set_mcp_tools(mcp_server, openai_tool_compatible=openai_tool_compatible, structured_output=context.structured_tool_output) log.info("MCP server lifetime setup complete") try: yield diff --git a/src/serena/resources/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml index e6b27b7d..4a636e42 100644 --- a/src/serena/resources/config/contexts/claude-code.yml +++ b/src/serena/resources/config/contexts/claude-code.yml @@ -50,3 +50,7 @@ tool_description_overrides: {} # Tools explicitly disabled by the project will not be available at all. # The `activate_project` tool is always disabled in this case, as project switching cannot be allowed. single_project: true + +# Claude Code has a bug (it does not unpack structured tool output, causing a lot of unnecessary escaping +# owing to the wrapped structure), so we disable it here. +structured_tool_output: false diff --git a/src/serena/resources/config/contexts/context.template.yml b/src/serena/resources/config/contexts/context.template.yml index f9873413..ba2fd963 100644 --- a/src/serena/resources/config/contexts/context.template.yml +++ b/src/serena/resources/config/contexts/context.template.yml @@ -15,4 +15,7 @@ tool_description_overrides: {} # If set to true and a project is provided at startup, the set of tools is limited to those required by the project's # concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal. # The `activate_project` tool will, therefore, be disabled in this case, as project switching is not allowed. -single_project: false \ No newline at end of file +single_project: false + +# whether to make MCP tools return structured output (null = auto-detect, true = always, false = never) +structured_tool_output: null diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index c37daee4..304333f3 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -246,12 +246,12 @@ class Tool(Component): """Gets the docstring for the tool application, used by the MCP server.""" return self.get_apply_docstring_from_cls() - def get_apply_fn_metadata(self) -> FuncMetadata: + def get_apply_fn_metadata(self, structured_output: bool | None = None) -> FuncMetadata: """Gets the metadata for the tool application function, used by the MCP server.""" - return self.get_apply_fn_metadata_from_cls() + return self.get_apply_fn_metadata_from_cls(structured_output=structured_output) @classmethod - def get_apply_fn_metadata_from_cls(cls) -> FuncMetadata: + def get_apply_fn_metadata_from_cls(cls, structured_output: bool | None = None) -> FuncMetadata: """Get the metadata for the apply method from the class (static metadata). Needed for creating MCP tools in a separate process without running into serialization issues. """ @@ -264,7 +264,7 @@ class Tool(Component): if apply_fn is None: raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?") - return func_metadata(apply_fn, skip_names=["self", "cls", cls.SESSION_ID_PARAM_NAME]) + return func_metadata(apply_fn, skip_names=["self", "cls", cls.SESSION_ID_PARAM_NAME], structured_output=structured_output) def _log_tool_application(self, frame: Any, session_id: str) -> None: params = {} From 8e7c8feb842d0e6d798d6fdb3b8068ee755e881e Mon Sep 17 00:00:00 2001 From: Lauritz-Configo <lati@configo.ai> Date: Sat, 27 Jun 2026 19:29:43 +0200 Subject: [PATCH 034/170] Fix Windows C# startup and Roslyn install --- .../csharp_language_server.py | 12 +++++-- src/solidlsp/language_servers/omnisharp.py | 9 +++-- .../csharp/test_csharp_nuget_download.py | 32 ++++++++++++++++- .../solidlsp/csharp/test_omnisharp_startup.py | 35 +++++++++++++++++++ 4 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 test/solidlsp/csharp/test_omnisharp_startup.py diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 51bed54e..66049a7e 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -6,6 +6,7 @@ import logging import os import platform import shutil +import tempfile import threading from collections.abc import Hashable, Iterable from dataclasses import replace @@ -34,6 +35,8 @@ log = logging.getLogger(__name__) NUGET_ALLOWED_HOSTS = ("www.nuget.org", "nuget.org", "globalcdn.nuget.org") DEFAULT_CSHARP_LANGUAGE_SERVER_VERSION = "5.5.0-2.26078.4" +WINDOWS_MAX_PATH = 260 +ROSLYN_LONGEST_KNOWN_PACKAGE_MEMBER = "Microsoft.CodeAnalysis.ExternalAccess.CompilerDeveloperSDK.dll" _RUNTIME_DEPENDENCIES = [ RuntimeDependency( @@ -460,12 +463,15 @@ class CSharpLanguageServer(SolidLanguageServer): if url is None: raise SolidLSPException(f"No URL specified for package {package_name} version {package_version}") - temp_dir = Path(self._ls_resources_dir) / "temp_downloads" - temp_dir.mkdir(parents=True, exist_ok=True) + package_extract_dir = Path(self._ls_resources_dir) / "temp_downloads" / f"{package_name}.{package_version}" + projected_path = package_extract_dir / (dependency.extract_path or "") / ROSLYN_LONGEST_KNOWN_PACKAGE_MEMBER + if platform.system() == "Windows" and len(str(projected_path)) >= WINDOWS_MAX_PATH: + log.warning("Using a short temporary directory for Roslyn package extraction because the configured cache path is too deep") + package_extract_dir = Path(tempfile.mkdtemp(prefix="serena-roslyn-")) / package_extract_dir.name + package_extract_dir.parent.mkdir(parents=True, exist_ok=True) try: log.debug(f"Downloading package from: {url}") - package_extract_dir = temp_dir / f"{package_name}.{package_version}" FileUtils.download_and_extract_archive_verified( url, str(package_extract_dir), diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py index f4ece965..58dda4ee 100644 --- a/src/solidlsp/language_servers/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -415,5 +415,10 @@ class OmniSharp(SolidLanguageServer): if "referencesProvider" in init_response["capabilities"] and init_response["capabilities"]["referencesProvider"]: self.references_available.set() - self.definition_available.wait() - self.references_available.wait() + missing_capabilities = [ + name + for event, name in ((self.definition_available, "definition"), (self.references_available, "references")) + if not event.is_set() + ] + if missing_capabilities: + log.warning("OmniSharp did not advertise capabilities during initialization: %s", ", ".join(missing_capabilities)) diff --git a/test/solidlsp/csharp/test_csharp_nuget_download.py b/test/solidlsp/csharp/test_csharp_nuget_download.py index 2319766d..548dfeb7 100644 --- a/test/solidlsp/csharp/test_csharp_nuget_download.py +++ b/test/solidlsp/csharp/test_csharp_nuget_download.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest from solidlsp.language_servers.common import RuntimeDependency -from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer +from solidlsp.language_servers.csharp_language_server import ROSLYN_LONGEST_KNOWN_PACKAGE_MEMBER, CSharpLanguageServer from solidlsp.settings import SolidLSPSettings @@ -128,3 +128,33 @@ class TestNuGetOrgDownload: "solidlsp.language_servers.csharp_language_server.FileUtils.download_and_extract_archive_verified", ): dependency_provider._download_nuget_package(test_dependency) + + def test_download_nuget_package_uses_short_extraction_path_for_deep_windows_cache(self, tmp_path: Path): + provider = object.__new__(CSharpLanguageServer.DependencyProvider) + provider._ls_resources_dir = str(tmp_path.joinpath(*(["nested-cache-root"] * 8))) + + test_dependency = RuntimeDependency( + id="TestPackage", + package_name="roslyn-language-server.win-x64", + package_version="5.5.0-2.26078.4", + url="https://www.nuget.org/api/v2/package/roslyn-language-server.win-x64/5.5.0-2.26078.4", + extract_path="tools/net10.0/win-x64", + ) + + def fake_download_and_extract( + _url: str, + target_path: str, + *_args: object, + **_kwargs: object, + ) -> None: + deepest_package_member = Path(target_path) / f"tools/net10.0/win-x64/{ROSLYN_LONGEST_KNOWN_PACKAGE_MEMBER}" + assert len(str(deepest_package_member)) < 260 + + with ( + patch("solidlsp.language_servers.csharp_language_server.platform.system", return_value="Windows"), + patch( + "solidlsp.language_servers.csharp_language_server.FileUtils.download_and_extract_archive_verified", + side_effect=fake_download_and_extract, + ), + ): + provider._download_nuget_package(test_dependency) diff --git a/test/solidlsp/csharp/test_omnisharp_startup.py b/test/solidlsp/csharp/test_omnisharp_startup.py new file mode 100644 index 00000000..74b9df34 --- /dev/null +++ b/test/solidlsp/csharp/test_omnisharp_startup.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from solidlsp.language_servers.omnisharp import OmniSharp + + +class _NoWaitEvent: + def is_set(self) -> bool: + return False + + def wait(self, timeout: float | None = None) -> bool: + raise AssertionError("OmniSharp startup must not wait for dynamic capability registration") + + +@pytest.mark.csharp +def test_omnisharp_startup_does_not_wait_for_capability_registration(tmp_path: Path) -> None: + omnisharp = OmniSharp.__new__(OmniSharp) + omnisharp.repository_root_path = str(tmp_path) + omnisharp.server = SimpleNamespace( + send=SimpleNamespace(initialize=lambda _params: {"capabilities": {}}), + notify=SimpleNamespace(initialized=lambda _params: None, workspace_did_change_configuration=lambda _params: None), + on_request=lambda *_args: None, + on_notification=lambda *_args: None, + start=lambda: None, + ) + omnisharp.server_ready = _NoWaitEvent() + omnisharp.definition_available = _NoWaitEvent() + omnisharp.references_available = _NoWaitEvent() + omnisharp.completions_available = _NoWaitEvent() + + omnisharp._start_server() From b7e7b86dd1225f451dbbb33bf6c544cb9ab3577a Mon Sep 17 00:00:00 2001 From: PETRO YAKOVYSHYN <petroyakovyshyn@gmail.com> Date: Sun, 28 Jun 2026 03:59:29 -0700 Subject: [PATCH 035/170] Unreal Engine: reorder the setup guide, fail on an empty compile_commands.json (#1610) --- CHANGELOG.md | 9 +- .../unreal_engine_setup_guide_for_serena.md | 92 +++++++++++++++---- .../clangd_language_server.py | 31 +++++-- test/solidlsp/cpp/test_cpp_unreal_unit.py | 15 +++ 4 files changed, 113 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc684b3f..b1a31855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,14 +29,7 @@ Status of the `main` branch. Changes prior to the next official version change w (which does not correctly unpack structured output) #1042 * Language Servers: - - C/C++ (clangd): add Unreal Engine 5 fixture and tests verifying that reflection-macro - code (`UCLASS`, `UFUNCTION`, `UPROPERTY`, `GENERATED_BODY`) yields correct symbols, - references, definitions, and rename edits in hand-written sources (never in - UHT-generated files); add an Unreal Engine setup guide. - - C/C++ (clangd, ccls): detect Unreal Engine projects by a `.uproject` file at the project - root and skip the engine build/cache directories (`Binaries`, `DerivedDataCache`, - `Intermediate`, `Saved`) during indexing. clangd fails with a pointer to the Unreal Engine - setup guide when started in such a project without a `compile_commands.json`. #1566 + - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - `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 diff --git a/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md b/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md index 8f5fb6af..7c5d8b34 100644 --- a/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md +++ b/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md @@ -21,30 +21,80 @@ file by default; this guide shows how to obtain it. --- ## Getting a compilation database -If clangd starts in a project that has a `.uproject` file but no `compile_commands.json`, -Serena fails the language server startup with an error that points back to this guide, -because clangd cannot resolve engine headers or reflection macros without the database. -Pick one of the following routes to create it. +If clangd starts in a project that has a `.uproject` file but no usable +`compile_commands.json`, Serena fails the language server startup with an error that +inlines the command below, because clangd cannot resolve engine headers or reflection +macros without the database. Pick one of the following routes to create it. -### Route 1: VSCode project files (no extra installs) +None of these routes change how you build. You still compile with MSVC. A clang toolchain, +where a route uses one, only generates the database that clangd reads. -UnrealBuildTool's VSCode project generator emits per-project compile commands. -Run it from your engine installation (VSCode itself is not required): - - <Engine>\Build\BatchFiles\Build.bat -projectfiles -project="<YourProject>.uproject" -game -VSCode - -This produces `.vscode/compileCommands_<YourProject>.json` inside your project. -Copy or symlink it to the project root as `compile_commands.json`. - -If you already use VSCode with UE, the file likely exists; the editor's -"Tools > Refresh Visual Studio Code Project" action maintains it. - -### Route 2: UnrealBuildTool's clang database mode (requires LLVM installed) +### Route 1 (recommended): UnrealBuildTool's clang database <Engine>\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.exe -mode=GenerateClangDatabase -project="<YourProject>.uproject" <YourProject>Editor Win64 Development -OutputDir="<YourProject's directory>" -This emits clang-native commands (cleanest flags for clangd) but requires a Clang -toolchain installed on Windows. +This emits clang-native commands. Each entry carries the system-include paths, so clangd +resolves system and engine headers without guessing. + +It needs a clang toolchain. Install one from the Visual Studio Installer: +**Modify > Individual Components > "C++ Clang tools for Windows"**. This is not a separate +LLVM download. UnrealBuildTool auto-detects it at `VC\Tools\Llvm\x64`. + +- UnrealBuildTool searches for clang in this order: `C:\Program Files\LLVM`, then the + `LLVM_PATH` environment variable, then the VS-bundled `VC\Tools\Llvm\x64`, then AutoSDK. + A standalone LLVM install wins if present. Remove it to fall back to the version-matched VS one. +- UE 5.7 expects clang in roughly the 18.1.8 to 20.1.8 range, and the VS component is + matched. A newer standalone (e.g. 22.x) still generates the database but prints + `Clang compiler version ... is not a preferred version`. +- `-OutputDir` is required. Without it the file lands in the engine root. +- Build the editor target once before generating, so the `*.generated.h` headers exist. + After a build you can add `-NoExecCodeGenActions` to skip redundant codegen. + +### Route 2 (no clang toolchain): MSVC database + +If you cannot add the clang component, append `-Compiler=VisualStudio2022`: + + <Engine>\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.exe -mode=GenerateClangDatabase -project="<YourProject>.uproject" <YourProject>Editor Win64 Development -Compiler=VisualStudio2022 -OutputDir="<YourProject's directory>" + +This produces an MSVC `cl.exe` database and needs no extra toolchain. The entries omit +system-include paths, which MSVC reads from the `INCLUDE` environment variable rather than +the database, so clangd may not resolve standard headers like `<vector>` and logs +`Failed to compile ... index may be incomplete` per file. Symbol tools still work, since +clangd indexes through those errors. To stop them from truncating symbol trees, add a +`.clangd` at the project root (see Troubleshooting): + + CompileFlags: + Add: [-D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH, -ferror-limit=200] + +### Route 3 (situational): VSCode project files + +UnrealBuildTool's VSCode generator emits per-project compile commands: + + <Engine>\Build\BatchFiles\Build.bat -projectfiles -project="<YourProject>.uproject" -game -VSCode + +On an installed engine (Launcher/Fab) with a project outside the engine tree, this writes +an empty array. UnrealBuildTool treats the project as foreign and emits no commands. Verify +the output is a non-empty JSON array before relying on it: + + <YourProject>\.vscode\compileCommands_Default.json + +If it contains entries, copy or symlink it to the project root as `compile_commands.json`. +If it is `[]`, use Route 1 or Route 2. The generator works reliably only for source-built +or in-tree engines, or when refreshed from inside the editor via +**Tools > Refresh Visual Studio Code Project**. + +### Notes for Rider users + +Building the project in Rider never produces `compile_commands.json`. `-VSCode` is a +project-file generator, not an editor mode, and the `.vscode/` directory it creates is +safe to delete afterwards. Use Route 1. + +### When to regenerate + +Regenerate only when you add a module or change a `*.Build.cs` (new files or compiler +flags). clangd watches `compile_commands.json` and reloads automatically, so routine edits +need no regeneration. If Serena failed to start because the database was missing or empty, +reconnect or restart the MCP after creating it. Serena reads the database only at startup. --- ## Build artifact directories @@ -73,6 +123,10 @@ To exclude further paths, add them to `ignored_paths` in your project's - **Symbol searches on large projects:** prefer passing `relative_path` to `find_symbol`. An unscoped search visits every translation unit, and UE files are expensive to parse because each pulls in large engine headers. +- **clangd index logs (MSVC database):** an `Indexed ... (N symbols)` line is a success. + A following `Failed to compile ... index may be incomplete` means clangd hit errors + while parsing that file but indexed it anyway. Raise `-ferror-limit` via `.clangd` if + symbol trees look truncated (see Troubleshooting). --- ## Troubleshooting diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 66613b76..d9830ff4 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -111,6 +111,27 @@ class ClangdLanguageServer(SolidLanguageServer): or (is_unreal_engine_project(self.repository_root_path) and dirname in UE_IGNORED_DIRNAMES) ) + def _raise_if_unreal_without_compile_db(self) -> None: + """Raise if this is an Unreal Engine project without a usable compilation database. + + clangd cannot resolve engine headers or macros without one. Non-UE projects return + without raising, since clangd works without a database there. + """ + if not is_unreal_engine_project(self.repository_root_path): + return + raise SolidLSPException( + f"No usable compile_commands.json at {self.repository_root_path}, but this looks like an " + "Unreal Engine project (.uproject present). clangd needs a non-empty compilation database " + "to resolve engine headers and macros.\n\n" + "Generate one from the engine's <Engine>\\Binaries\\DotNET\\UnrealBuildTool\\ directory:\n" + ' UnrealBuildTool.exe -mode=GenerateClangDatabase -project="<Proj>.uproject" ' + '<Proj>Editor Win64 Development -OutputDir="<projectDir>"\n' + "Without a clang toolchain, append -Compiler=VisualStudio2022 to build an MSVC database instead.\n\n" + "Build the editor target once first so the generated headers exist. After creating the " + "database, reconnect or restart Serena's MCP so the language server re-checks for it.\n" + "See docs/03-special-guides/unreal_engine_setup_guide_for_serena.md for details." + ) + def _prepare_compile_commands(self) -> str | None: """ Prepare clangd compilation database with absolute directory paths. @@ -132,13 +153,7 @@ class ClangdLanguageServer(SolidLanguageServer): if not os.path.exists(compile_db_path): # clangd can't resolve UE engine headers without the database; fail with guidance. - if is_unreal_engine_project(self.repository_root_path): - raise SolidLSPException( - f"No compile_commands.json found at {self.repository_root_path}, but this looks like an " - "Unreal Engine project (.uproject present). clangd needs a compilation database to resolve " - "engine headers and macros. See docs/03-special-guides/" - "unreal_engine_setup_guide_for_serena.md to generate one." - ) + self._raise_if_unreal_without_compile_db() return None try: @@ -146,6 +161,8 @@ class ClangdLanguageServer(SolidLanguageServer): compile_commands = json.load(f) if not compile_commands: + # An empty [] database is treated like a missing one. + self._raise_if_unreal_without_compile_db() return None # Check if any entries have relative directory paths diff --git a/test/solidlsp/cpp/test_cpp_unreal_unit.py b/test/solidlsp/cpp/test_cpp_unreal_unit.py index 68d18e6c..cde68c49 100644 --- a/test/solidlsp/cpp/test_cpp_unreal_unit.py +++ b/test/solidlsp/cpp/test_cpp_unreal_unit.py @@ -43,6 +43,21 @@ def test_missing_compile_commands_returns_none_for_non_unreal_project(tmp_path): assert server._prepare_compile_commands() is None +def test_empty_compile_commands_raises_for_unreal_project(tmp_path): + (tmp_path / "MyGame.uproject").write_text("{}") + (tmp_path / "compile_commands.json").write_text("[]") + server = _make_clangd(tmp_path) + + with pytest.raises(SolidLSPException, match="Unreal Engine"): + server._prepare_compile_commands() + + +def test_empty_compile_commands_returns_none_for_non_unreal_project(tmp_path): + (tmp_path / "compile_commands.json").write_text("[]") + server = _make_clangd(tmp_path) + assert server._prepare_compile_commands() is None + + @pytest.mark.parametrize("make_server", [_make_clangd, _make_ccls]) def test_unreal_dirs_ignored_only_for_unreal_project(make_server, tmp_path): non_ue = make_server(tmp_path) From 68a4af4ac19a3f8305bcf6929b43ba6c06bddb9b Mon Sep 17 00:00:00 2001 From: Devin <studyzy@gmail.com> Date: Sun, 28 Jun 2026 19:00:41 +0800 Subject: [PATCH 036/170] Add CodeBuddy agent integration (#1581) --- docs/02-usage/030_clients.md | 92 ++++++++++++++++++- src/serena/config/client_setup.py | 27 +++++- src/serena/hooks.py | 5 +- .../resources/config/contexts/codebuddy.yml | 52 +++++++++++ 4 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 src/serena/resources/config/contexts/codebuddy.yml diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 0a6e0af5..6241246e 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -525,6 +525,93 @@ You will have to prompt Antigravity's agent to "Activate the current project usi Unlike VSCode, Antigravity does not currently support including the working directory in the MCP configuration. Also, the current client will be shown as `none` in Serena's dashboard (Antigravity currently does not fully support the MCP specifications). This is not a problem, all tools will work as expected. +## CodeBuddy + +Serena provides native support for CodeBuddy, a CLI coding agent that shares a similar architecture with Claude Code. +To set up the Serena MCP server for CodeBuddy, simply run: + + serena setup codebuddy + +### Manual Setup + +**Global Configuration**. To add the Serena MCP server for all your projects, use the user-level configuration of CodeBuddy and the `--project-from-cwd` flag: + +```bash +codebuddy mcp add --scope user serena -- serena start-mcp-server --context codebuddy --project-from-cwd +``` + +**Project-Level Configuration**. To add the Serena MCP server for a single project only: + +```bash +codebuddy mcp add serena -- serena start-mcp-server --context codebuddy --project "$(pwd)" +``` + +Confirm that CodeBuddy is connected to Serena by running the `/mcp` command and reconnecting if necessary. + +### Hooks + +CodeBuddy supports the same hook system as Claude Code. To set up hooks, add the following to your CodeBuddy settings file (`.codebuddy/settings.json` in your project directory, or `~/.codebuddy/settings.json` globally): + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "serena-hooks remind --client=codebuddy" + } + ] + } + ], + "Notification": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "serena-hooks auto-approve --client=codebuddy" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "serena-hooks activate --client=codebuddy" + } + ] + } + ], + "Stop": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "serena-hooks cleanup --client=codebuddy" + } + ] + } + ] + } +} +``` + +### Hook Descriptions + +- **`remind`**: Remind the agent to use Serena's tools instead of built-in `grep` and `read` tools. +- **`activate`**: Prompt the agent to activate the project at session start and read Serena's instructions. +- **`cleanup`**: Clean up hook session data when the session ends. +- **`auto-approve`**: Auto-approve Serena tool calls whenever CodeBuddy is in a permissive + permission mode (`acceptEdits` or `auto`), so blanket approvals cover Serena's destructive + tools (e.g. `replace_symbol_body`, `rename_symbol`) instead of prompting on every call. + ## Other Clients For other clients, follow the [general instructions](#clients-general-instructions) above to set up Serena as an MCP server. @@ -536,8 +623,9 @@ There are many terminal-based coding assistants that support MCP servers, such a * [Gemini-CLI](https://github.com/google-gemini/gemini-cli), * [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder), * [rovodev](https://community.atlassian.com/forums/Rovo-for-Software-Teams-Beta/Introducing-Rovo-Dev-CLI-AI-Powered-Development-in-your-terminal/ba-p/3043623), - * [OpenHands CLI](https://docs.all-hands.dev/usage/how-to/cli-mode) and - * [opencode](https://github.com/sst/opencode). + * [OpenHands CLI](https://docs.all-hands.dev/usage/how-to/cli-mode), + * [opencode](https://github.com/sst/opencode) and + * [CodeBuddy-Code](https://www.codebuddy.cn/cli/). They generally benefit from the symbolic tools provided by Serena. You might want to customize some aspects of Serena by writing your own context, modes or prompts to adjust it to the client's respective internal capabilities (and your general workflow). diff --git a/src/serena/config/client_setup.py b/src/serena/config/client_setup.py index 9116d01f..933ff4a3 100644 --- a/src/serena/config/client_setup.py +++ b/src/serena/config/client_setup.py @@ -53,7 +53,7 @@ class ClientSetupHandlerClaudeCode(ClientSetupHandler): super().__init__("claude-code") def is_applicable(self) -> bool: - result = execute_shell_command("claude --version") + result = execute_shell_command("claude --version", capture_stderr=True) return result.return_code == 0 and "Claude" in result.stdout def get_mcp_server_options(self) -> list[str]: @@ -78,7 +78,7 @@ class ClientSetupHandlerCodex(ClientSetupHandler): super().__init__("codex") def is_applicable(self) -> bool: - result = execute_shell_command("codex --version") + result = execute_shell_command("codex --version", capture_stderr=True) return result.return_code == 0 and "codex-cli" in result.stdout def get_mcp_server_options(self) -> list[str]: @@ -88,4 +88,25 @@ class ClientSetupHandlerCodex(ClientSetupHandler): return self._run_shell_command(f"codex mcp add serena -- {self.get_mcp_server_command()}") -client_setup_handlers = [ClientSetupHandlerClaudeCode(), ClientSetupHandlerCodex()] +class ClientSetupHandlerCodeBuddy(ClientSetupHandler): + def __init__(self) -> None: + super().__init__("codebuddy") + + def is_applicable(self) -> bool: + result = execute_shell_command("codebuddy --version", capture_stderr=True) + return result.return_code == 0 + + def get_mcp_server_options(self) -> list[str]: + return ["--context=codebuddy", "--project-from-cwd"] + + def apply(self) -> bool: + cmd = f"codebuddy mcp add --scope user serena -- {self.get_mcp_server_command()}" + is_success = self._run_shell_command(cmd) + if is_success: + click.echo("\nIMPORTANT: We additionally recommend to set up hooks for CodeBuddy to ensure the best experience.") + click.echo(" Please read the instructions here:") + click.echo(" https://oraios.github.io/serena/02-usage/030_clients.html#codebuddy") + return is_success + + +client_setup_handlers = [ClientSetupHandlerClaudeCode(), ClientSetupHandlerCodeBuddy(), ClientSetupHandlerCodex()] diff --git a/src/serena/hooks.py b/src/serena/hooks.py index 83a0a8cc..e4c76769 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -22,6 +22,7 @@ class HookClient(Enum): """The client application that triggered the hook.""" CLAUDE_CODE = "claude-code" + CODEBUDDY = "codebuddy" VSCODE = "vscode" CODEX = "codex" @@ -365,7 +366,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook): self._file_path = str(file_path).strip() or None def is_grep_call(self) -> bool: - if self._client == HookClient.CLAUDE_CODE: + if self._client in (HookClient.CLAUDE_CODE, HookClient.CODEBUDDY): return self._tool_name == "grep" or "search_for_pattern" in self._tool_name if self._client == HookClient.CODEX and self._is_shell_command_call(): return self._command_name in self._GREP_SHELL_COMMANDS @@ -373,7 +374,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook): return "grep" in self._tool_name def is_read_call(self) -> bool: - if self._client == HookClient.CLAUDE_CODE: + if self._client in (HookClient.CLAUDE_CODE, HookClient.CODEBUDDY): return self._tool_name == "read" or "read_file" in self._tool_name if self._client == HookClient.CODEX and self._is_shell_command_call(): return self._command_name in self._READ_SHELL_COMMANDS diff --git a/src/serena/resources/config/contexts/codebuddy.yml b/src/serena/resources/config/contexts/codebuddy.yml new file mode 100644 index 00000000..9e2ff53e --- /dev/null +++ b/src/serena/resources/config/contexts/codebuddy.yml @@ -0,0 +1,52 @@ +description: CodeBuddy (CLI agent where file operations, basic edits, etc. are already covered; single project mode) +prompt: | + You are running in a CLI coding agent context where file operations, basic (line-based) edits and reads + as well as shell commands are handled by your own, internal tools. + + You have access to Serena's code intelligence tools that exploit the symbolic + structure of the code and are much more efficient than your own tools for most coding scenarios. + If you are working on any coding task and if Serena's tools are deferred, you + should load them all immediately, before performing any read, grep or bash commands. + If Serena's tools can be used to achieve your task, you should prioritize them. + **CRITICAL**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them + **right now**. + For exploring and reading code in a token-efficient manner, use Serena's code-centric overview and symbolic search tools. + For edits, prefer Serena's editing tools since they are more token efficient. + Especially when editing code you have read through Serena, **ALWAYS** use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content). + **CRITICAL**: Never use a tool before having read its schema via the tool-search tool! + + **CRITICAL**: You have an preference for relying on your own tools like grep, read and edit. It is of paramount + importance that you overcome this preference and instead develop a preference for using Serena's tools since they + are superior for most coding tasks. Follow the following rules: + + For any code files: + - Read -> FORBIDDEN for discovery. Use get_symbols_overview, then find_symbol with include_body. + You may use read only if you already have an overview of the file and if reading whole symbols + is inappropriate (e.g. a few lines of code). + - Glob (by name) -> Allowed for discovery only. + - Grep (content) -> Allowed for discovery only; follow up reads or reference searches must be Serena. + - Edit -> FORBIDDEN. Use replace_symbol_body / insert_*_symbol / replace_content. + + Disallowed reasoning. Do NOT use any of the following to justify Read/Edit on a code file: + - "I already know the path" + - "one Read call is faster than three Serena calls" + - "the built-in tool description says to use Read for known paths" + If you catch yourself reaching for one of these, that is the signal to switch to Serena. + +excluded_tools: + - create_text_file + - read_file + - execute_shell_command + - find_file + - list_dir + - search_for_pattern + +tool_description_overrides: {} + +# whether to assume that Serena shall only work on a single project in this context (provided that a project is given +# when Serena is started). +# If set to true and a project is provided at startup, the set of tools is limited to those required by the project's +# concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal. +# Tools explicitly disabled by the project will not be available at all. +# The `activate_project` tool is always disabled in this case, as project switching cannot be allowed. +single_project: true From 644eb763ebb8a4afc414240dc12e784205a67233 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 13:18:53 +0200 Subject: [PATCH 037/170] Skip BSL tests, fix outdated comment --- pyproject.toml | 4 +--- test/solidlsp/bsl/test_bsl_basic.py | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 50df7387..9a1aafbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,9 +144,7 @@ PYDEVD_DISABLE_FILE_VALIDATION = "1" type = "simple" [tool.poe.tasks] -# Uses PYTEST_MARKERS env var for default markers -# For custom markers, one can either adjust the env var or just use -m option in the command line, -# as the second -m option will override the first one. +# For custom markers, use poe test -m ... test = "pytest test -vv" _ruff_check = "ruff check src scripts test" _ruff_format_check = "ruff format --check src scripts test" diff --git a/test/solidlsp/bsl/test_bsl_basic.py b/test/solidlsp/bsl/test_bsl_basic.py index b58ab984..67af8cdd 100644 --- a/test/solidlsp/bsl/test_bsl_basic.py +++ b/test/solidlsp/bsl/test_bsl_basic.py @@ -10,6 +10,7 @@ from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +pytestmark = pytest.mark.skip("BSL is a niche language, the tests are slow and flaky.") @pytest.mark.bsl class TestBSLLanguageServer: From 595cf19b8760da697a2ae74b2f6d67c08257dc4d Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 13:26:28 +0200 Subject: [PATCH 038/170] Remove niche languages with heavy toolchains from execution in GH actions --- .github/workflows/pytest.yml | 77 ------------------- test/solidlsp/bsl/test_bsl_basic.py | 1 + test/solidlsp/haskell/test_haskell_basic.py | 2 + test/solidlsp/julia/test_julia_basic.py | 3 +- test/solidlsp/ocaml/test_ocaml_basic.py | 3 +- test/solidlsp/ocaml/test_ocaml_diagnostics.py | 3 + test/solidlsp/r/test_r_basic.py | 3 +- 7 files changed, 9 insertions(+), 83 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index efb77935..9cf5535b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -92,12 +92,6 @@ jobs: if: steps.cache-go-binaries.outputs.cache-hit != 'true' shell: bash run: go install golang.org/x/tools/gopls@latest - - name: Set up Elixir - if: runner.os != 'Windows' - uses: erlef/setup-beam@v1 - with: - elixir-version: "1.19.3" - otp-version: "28" # Erlang currently not tested in CI, random hangings on macos, always hangs on ubuntu # In local tests, erlang seems to work though # - name: Install Erlang Language Server @@ -202,77 +196,6 @@ jobs: - name: Install Ruby language server shell: bash run: gem install ruby-lsp - - name: Install OCaml and opam - uses: ocaml/setup-ocaml@v3 - with: - ocaml-compiler: ${{ runner.os == 'Windows' && '4.14' || '5.3.x' }} - dune-cache: true - opam-repositories: | - ${{ runner.os == 'Windows' && 'opam-repository-mingw: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset' || '' }} - default: https://github.com/ocaml/opam-repository.git - - name: Install OCaml packages - shell: bash - run: | - if [ "$RUNNER_OS" = "Windows" ]; then - opam install -y dune ocaml-lsp-server - else - # Require ocaml-lsp-server >= 1.23.0 for cross-file reference support - opam install -y dune 'ocaml-lsp-server>=1.23.0' - fi - - name: Install R - uses: r-lib/actions/setup-r@v2 - with: - r-version: '4.4.2' - use-public-rspm: true - - name: Install R sysdeps (libuv for fs package) - if: runner.os == 'Linux' - shell: bash - run: sudo apt-get update && sudo apt-get install -y libuv1-dev - - name: Install R language server - shell: bash - run: | - Rscript -e "install.packages('languageserver', repos='https://cloud.r-project.org')" - - name: Set up Julia - uses: julia-actions/setup-julia@v2 - with: - version: '1.12.6' - - name: Install Julia LanguageServer - shell: bash - run: julia -e 'using Pkg; Pkg.add(Pkg.PackageSpec(name="LanguageServer", version="5.0.0"))' - - name: Setup Haskell toolchain - if: runner.os != 'Windows' - uses: haskell/ghcup-setup@v1 - with: - ghc: '9.12.2' - cabal: '3.10.3.0' - hls: '2.11.0.0' - - name: Verify Haskell tools - if: runner.os != 'Windows' - run: | - echo "Verifying installed Haskell tools:" - which ghc && ghc --version - which cabal && cabal --version - # HLS verification - non-blocking in case of version incompatibility - if command -v haskell-language-server-wrapper &>/dev/null; then - echo "Found haskell-language-server-wrapper" - haskell-language-server-wrapper --version || echo "WARNING: HLS wrapper found but version check failed" - elif command -v haskell-language-server &>/dev/null; then - echo "Found haskell-language-server" - haskell-language-server --version || echo "WARNING: HLS found but version check failed" - else - echo "WARNING: HLS not found (may be incompatible with GHC 9.12.2)" - echo "This is not a critical error - tests will use HLS if available at runtime" - fi - shell: bash - - name: Pre-build Haskell test project for HLS - if: runner.os != 'Windows' - run: | - cd test/resources/repos/haskell/test_repo - cabal update - cabal build --only-dependencies - cabal build - echo "Haskell test project built successfully" - shell: bash - name: Install Zig uses: mlugg/setup-zig@v2 with: diff --git a/test/solidlsp/bsl/test_bsl_basic.py b/test/solidlsp/bsl/test_bsl_basic.py index 67af8cdd..4e2ecc87 100644 --- a/test/solidlsp/bsl/test_bsl_basic.py +++ b/test/solidlsp/bsl/test_bsl_basic.py @@ -12,6 +12,7 @@ from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, pytestmark = pytest.mark.skip("BSL is a niche language, the tests are slow and flaky.") + @pytest.mark.bsl class TestBSLLanguageServer: @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py index 5b23ad3d..55f94f6d 100644 --- a/test/solidlsp/haskell/test_haskell_basic.py +++ b/test/solidlsp/haskell/test_haskell_basic.py @@ -23,6 +23,8 @@ from solidlsp.ls_types import SymbolKind from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics +pytestmark = pytest.mark.skip("Haskell installation is heavy, the tests should be executed only on demand") + @pytest.mark.haskell @pytest.mark.skipif(sys.platform == "win32", reason="HLS not installed on Windows CI") diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py index 176587f7..1f3fdd87 100644 --- a/test/solidlsp/julia/test_julia_basic.py +++ b/test/solidlsp/julia/test_julia_basic.py @@ -4,12 +4,11 @@ import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -@pytest.mark.skipif(shutil.which("julia") is None and not is_ci, reason="Julia is not available") +@pytest.mark.skipif(shutil.which("julia") is None, reason="Julia is not available") @pytest.mark.julia class TestJuliaLanguageServer: @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) diff --git a/test/solidlsp/ocaml/test_ocaml_basic.py b/test/solidlsp/ocaml/test_ocaml_basic.py index f3dca2eb..6016be56 100644 --- a/test/solidlsp/ocaml/test_ocaml_basic.py +++ b/test/solidlsp/ocaml/test_ocaml_basic.py @@ -6,11 +6,10 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils -from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -@pytest.mark.skipif(shutil.which("opam") is None and not is_ci, reason="OPAM is not available") +@pytest.mark.skipif(shutil.which("opam") is None, reason="OPAM is not available") @pytest.mark.ocaml class TestOCamlLanguageServer: @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) diff --git a/test/solidlsp/ocaml/test_ocaml_diagnostics.py b/test/solidlsp/ocaml/test_ocaml_diagnostics.py index a76f95cb..69f4ed61 100644 --- a/test/solidlsp/ocaml/test_ocaml_diagnostics.py +++ b/test/solidlsp/ocaml/test_ocaml_diagnostics.py @@ -1,3 +1,5 @@ +import shutil + import pytest from solidlsp import SolidLanguageServer @@ -5,6 +7,7 @@ from solidlsp.ls_config import Language from test.solidlsp.util.diagnostics import assert_file_diagnostics +@pytest.mark.skipif(shutil.which("opam") is None, reason="OPAM is not available") @pytest.mark.ocaml class TestOcamlDiagnostics: @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) diff --git a/test/solidlsp/r/test_r_basic.py b/test/solidlsp/r/test_r_basic.py index 76f82602..17f28545 100644 --- a/test/solidlsp/r/test_r_basic.py +++ b/test/solidlsp/r/test_r_basic.py @@ -10,11 +10,10 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -@pytest.mark.skipif(shutil.which("R") is None and not is_ci, reason="R is not available") +@pytest.mark.skipif(shutil.which("R") is None, reason="R is not available") @pytest.mark.r class TestRLanguageServer: """Test basic functionality of the R language server.""" From 52bbeefa6a096cfc082e393b311cf047e9d3c79e Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 13:31:21 +0200 Subject: [PATCH 039/170] Changelog - 2 new entries, shortened overly verbose existing entries --- CHANGELOG.md | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1a31855..49dde2b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,26 +30,18 @@ Status of the `main` branch. Changes prior to the next official version change w * Language Servers: - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - - `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. + - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. + Enables Yarn PnP setups with `typescript.tsdk` pointing at the Yarn-generated SDK. + - C#: minor fixes in Omnisharp and Roslyn that prevented startup on some systems #1617 - `SvelteLanguageServer`: Fix diagnostics requests for TypeScript/JavaScript files incorrectly being processed by the Svelte LS instead of the TypeScript LS. - `SvelteLanguageServer`: Fix document-symbol requests for TypeScript/JavaScript files returning empty - results in svelte-only mode (`languages: [svelte]`). They are now routed to the companion TS server, - so symbols defined in plain `.ts`/`.js` files are again discoverable via `find_symbol`/ - `get_symbols_overview`, and `find_referencing_symbols` no longer fails to locate `.ts`/`.js` symbols. #1552 + results in svelte-only mode (`languages: [svelte]`. #1552 - `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed") - when `julia` is enabled. The runtime probe/install subprocesses now run with `stdin=subprocess.DEVNULL`, - so they can no longer inherit and clobber Serena's stdin, which is the JSON-RPC pipe under the stdio - transport. #1577 + when `julia` is enabled. #1577 + - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 - Improve quoting of arguments in shell executions - - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). Auto-downloads a - SHA-256-verified prebuilt texlab binary (osx-arm64/x64, linux-arm64/x64, win-x64); covers `.tex`, `.bib`, - `.sty`, and `.cls` files. Provides sectioning document symbols (including beamer frames), label/citation - definitions, and `\ref`/`\cite` references. Must be explicitly enabled via language `latex`. texlab is - GPL-3.0 and runs as a separate downloaded process. + - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically From 91a18eb4654801ab78369e425795e4b2eac6b5bb Mon Sep 17 00:00:00 2001 From: Fabio Capucci <fabio.cappuc@gmail.com> Date: Sun, 28 Jun 2026 14:46:27 +0200 Subject: [PATCH 040/170] add phpantom lsp for php (#1554) --- CHANGELOG.md | 1 + docs/01-about/020_programming-languages.md | 3 +- src/solidlsp/language_servers/phpantom.py | 301 +++++++++++++++++++++ src/solidlsp/ls_config.py | 11 +- test/conftest.py | 3 + test/solidlsp/php/test_php_basic.py | 42 ++- test/solidlsp/php/test_php_diagnostics.py | 2 +- test/solidlsp/php/test_phpantom.py | 231 ++++++++++++++++ 8 files changed, 577 insertions(+), 17 deletions(-) create mode 100644 src/solidlsp/language_servers/phpantom.py create mode 100644 test/solidlsp/php/test_phpantom.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 49dde2b2..067d3a70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Status of the `main` branch. Changes prior to the next official version change w - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). + - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index a9be9c7e..a6c0e133 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -122,7 +122,8 @@ Some languages require additional installations or setup steps, as noted. (requires installation of Perl::LanguageServer) * **PHP** (by default, uses the Intelephense language server (language `php`), set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features; - we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+) + we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+; + and the experimental [PHPantom](https://github.com/PHPantom-dev/phpantom_lsp) backend (language `php_phpantom`) * **Python** * **R** (requires installation of the `languageserver` R package) diff --git a/src/solidlsp/language_servers/phpantom.py b/src/solidlsp/language_servers/phpantom.py new file mode 100644 index 00000000..6fd42f0a --- /dev/null +++ b/src/solidlsp/language_servers/phpantom.py @@ -0,0 +1,301 @@ +""" +Provides PHP specific instantiation of the LanguageServer class using PHPantom. +""" + +import logging +import os +import pathlib +import shutil +import stat +from time import sleep +from typing import cast + +from overrides import override + +from solidlsp import ls_types +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + LSPFileBuffer, + SolidLanguageServer, +) +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import PlatformId, PlatformUtils +from solidlsp.lsp_protocol_handler import lsp_types as protocol_lsp_types +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +PHPANTOM_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") + +# Version pinning convention (see eclipse_jdtls.py for the full spec): +# INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. +# DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +INITIAL_PHPANTOM_VERSION = "0.8.0" +DEFAULT_PHPANTOM_VERSION = "0.8.0" + +_PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION: dict[str, tuple[RuntimeDependency, ...]] = { + "0.8.0": ( + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for macOS (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-apple-darwin.tar.gz", + platform_id=PlatformId.OSX_arm64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="2cdfd103b5df98d20712eaeea9bd00d2b459e2a588296f1ab8e558fe25fde456", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for macOS (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-apple-darwin.tar.gz", + platform_id=PlatformId.OSX_x64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="e09eef93342cd38c9f9cc6c58064d81b005d06ec6d054e7cdeeec7698dc6c5da", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Linux (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-unknown-linux-gnu.tar.gz", + platform_id=PlatformId.LINUX_arm64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="e87fc96430f1bcc4966f953033a73a4e2ea53b2dbb7dc3e5f71cc8ced9022a57", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Linux (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-unknown-linux-gnu.tar.gz", + platform_id=PlatformId.LINUX_x64.value, + archive_type="gztar", + binary_name="phpantom_lsp", + sha256="39615b495e624bbafe8787c3be61acabc123ec5ac23e9b30e00ab7660f50e020", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Windows (arm64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-aarch64-pc-windows-msvc.zip", + platform_id=PlatformId.WIN_arm64.value, + archive_type="zip", + binary_name="phpantom_lsp.exe", + sha256="352bfd90351c0f35947ea1af458dabe7a4dc4753d0cabaf9711a23a61346a63d", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="phpantom_lsp", + description="PHPantom language server for Windows (x64)", + url="https://github.com/PHPantom-dev/phpantom_lsp/releases/download/0.8.0/phpantom_lsp-x86_64-pc-windows-msvc.zip", + platform_id=PlatformId.WIN_x64.value, + archive_type="zip", + binary_name="phpantom_lsp.exe", + sha256="17e23af816fc7ec695fe716f6209df6b0eafcec2fdafb5d9d72e2b352d5ddf83", + allowed_hosts=PHPANTOM_ALLOWED_HOSTS, + ), + ) +} + + +def _create_phpantom_dependencies(version: str) -> RuntimeDependencyCollection: + dependencies = _PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION.get(version) + if dependencies is None: + raise RuntimeError( + f"Unsupported phpantom_version '{version}'. " + + f"Known bundled versions: {', '.join(sorted(_PHPANTOM_RUNTIME_DEPENDENCIES_BY_VERSION))}" + ) + return RuntimeDependencyCollection(dependencies) + + +class PHPantomServer(SolidLanguageServer): + """ + Provides PHP specific instantiation of the LanguageServer class using PHPantom. + + PHPantom is an open-source PHP language server written in Rust. + It is an experimental alternative to Intelephense, which remains the default PHP language server. + + You can pass the following entries in ls_specific_settings["php_phpantom"]: + - ls_path: path to a pre-installed phpantom_lsp binary + - phpantom_version: override the pinned PHPantom version downloaded by Serena + - ignore_vendor: whether to ignore directories named "vendor" (default: true) + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Setup runtime dependencies for PHPantom and return the path to the executable. + """ + # checking PATH first + system_phpantom = shutil.which("phpantom_lsp") + if system_phpantom is not None: + log.info(f"Using system-installed phpantom_lsp at {system_phpantom}") + return system_phpantom + + # resolving the bundled binary + phpantom_version = self._custom_settings.get("phpantom_version", DEFAULT_PHPANTOM_VERSION) + dependencies = _create_phpantom_dependencies(phpantom_version) + binary_dirname = "phpantom-lsp" if phpantom_version == INITIAL_PHPANTOM_VERSION else f"phpantom-lsp-{phpantom_version}" + binary_dir = os.path.join(self._ls_resources_dir, binary_dirname) + binary_path = dependencies.binary_path(binary_dir) + if not os.path.exists(binary_path): + os.makedirs(binary_dir, exist_ok=True) + dep = dependencies.get_single_dep_for_current_platform("phpantom_lsp") + log.info(f"Downloading phpantom_lsp from {dep.url}") + dependencies.install(binary_dir) + + if not os.path.exists(binary_path): + raise FileNotFoundError(f"phpantom_lsp executable not found at {binary_path}") + + if PlatformUtils.get_platform_id() not in (PlatformId.WIN_x64, PlatformId.WIN_arm64): + current_mode = os.stat(binary_path).st_mode + os.chmod(binary_path, current_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + return binary_path + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path, "--stdio"] + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "php", solidlsp_settings) + self.request_id = 0 + self.language = Language.PHP_PHPANTOM + + self._ignored_dirnames = {"node_modules", "cache"} + if self._custom_settings.get("ignore_vendor", True): + self._ignored_dirnames.add("vendor") + log.info(f"Ignoring the following directories for PHP (PHPantom): {', '.join(sorted(self._ignored_dirnames))}") + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialization params for the PHPantom Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + + # declaring client capabilities + initialize_params = { + "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"]}, + }, + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return cast(InitializeParams, initialize_params) + + def _start_server(self) -> None: + """Start PHPantom server process.""" + + 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 + + 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) + + log.info("Starting PHPantom server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + # negotiating server capabilities + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("After sent initialize params") + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities + assert "completionProvider" in capabilities + assert "definitionProvider" in capabilities + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert capabilities.get("referencesProvider"), "PHPantom did not advertise references support" + + self.server.notify.initialized({}) + + @override + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[protocol_lsp_types.Location] | None: + # waiting for cross-file index updates + sleep(1) + return super()._send_references_request(relative_file_path, line, column) + + @override + def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: + # waiting for cross-file index updates + sleep(1) + return super()._send_definition_request(definition_params) + + @override + def request_hover( + self, + relative_file_path: str, + line: int, + column: int, + file_buffer: LSPFileBuffer | None = None, + ) -> ls_types.Hover | None: + # requesting direct hover info + hover = super().request_hover(relative_file_path, line, column, file_buffer=file_buffer) + if hover is not None: + return hover + + # falling back to a usage-site hover + references = self.request_references(relative_file_path, line, column) + for reference in references: + ref_relative_path = reference.get("relativePath") + if ref_relative_path is None: + continue + start = reference["range"]["start"] + if ref_relative_path == relative_file_path and start["line"] == line and start["character"] == column: + continue + usage_hover = super().request_hover(ref_relative_path, start["line"], start["character"]) + if usage_hover is not None: + return usage_hover + + return None diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 2c1850fa..9d36b453 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -154,6 +154,10 @@ class Language(str, Enum): """Phpactor language server for PHP (instead of Intelephense, which is the default). Requires PHP 8.1+ on the system. Fully open-source (MIT license). """ + PHP_PHPANTOM = "php_phpantom" + """PHPantom language server for PHP (instead of Intelephense, which is the default). + Uses the open-source Rust-based phpantom_lsp binary and can be auto-downloaded. + """ MARKDOWN = "markdown" """Marksman language server for Markdown (experimental). Must be explicitly specified as the main language, not auto-detected. @@ -252,6 +256,7 @@ class Language(str, Enum): self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.PHP_PHPACTOR, + self.PHP_PHPANTOM, self.MARKDOWN, self.LATEX, self.YAML, @@ -387,7 +392,7 @@ class Language(str, Enum): return FilenameMatcher(".kt", ".kts") case self.DART: return FilenameMatcher(".dart") - case self.PHP | self.PHP_PHPACTOR: + case self.PHP | self.PHP_PHPACTOR | self.PHP_PHPANTOM: return FilenameMatcher(".php") case self.R: return FilenameMatcher(".R", ".r", ".Rmd", ".Rnw") @@ -603,6 +608,10 @@ class Language(str, Enum): from solidlsp.language_servers.phpactor import PhpactorServer return PhpactorServer + case self.PHP_PHPANTOM: + from solidlsp.language_servers.phpantom import PHPantomServer + + return PHPantomServer case self.PERL: from solidlsp.language_servers.perl_language_server import PerlLanguageServer diff --git a/test/conftest.py b/test/conftest.py index 672a255b..eb826617 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -41,6 +41,7 @@ class LanguageParamRequest: _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.CPP_CCLS: Language.CPP, Language.PHP_PHPACTOR: Language.PHP, + Language.PHP_PHPANTOM: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, Language.PYTHON_TY: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, @@ -260,6 +261,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.MSL: [pytest.mark.msl], Language.PHP: [pytest.mark.php], Language.PHP_PHPACTOR: [pytest.mark.php], + Language.PHP_PHPANTOM: [pytest.mark.php], Language.POWERSHELL: [pytest.mark.powershell], Language.PYTHON: [pytest.mark.python], Language.PYTHON_JEDI: [pytest.mark.python], @@ -319,6 +321,7 @@ def _determine_disabled_languages() -> list[Language]: php_tests_enabled = _sh.which("php") is not None if not php_tests_enabled: result.append(Language.PHP_PHPACTOR) + result.append(Language.PHP_PHPANTOM) al_tests_enabled = True if not al_tests_enabled: diff --git a/test/solidlsp/php/test_php_basic.py b/test/solidlsp/php/test_php_basic.py index aab824f3..947495b7 100644 --- a/test/solidlsp/php/test_php_basic.py +++ b/test/solidlsp/php/test_php_basic.py @@ -19,6 +19,20 @@ _php_servers: list[Language] = [Language.PHP] if language_tests_enabled(Language.PHP_PHPACTOR): if not (is_windows and is_ci) and _php_supports_phpactor(): _php_servers.append(Language.PHP_PHPACTOR) +if language_tests_enabled(Language.PHP_PHPANTOM): + _php_servers.append(Language.PHP_PHPANTOM) + + +def _is_phpactor(language_server: SolidLanguageServer) -> bool: + return language_server.language_server.language == Language.PHP_PHPACTOR + + +def _is_phpantom(language_server: SolidLanguageServer) -> bool: + return language_server.language_server.language == Language.PHP_PHPANTOM + + +def _is_non_default_php_backend(language_server: SolidLanguageServer) -> bool: + return _is_phpactor(language_server) or _is_phpantom(language_server) @pytest.mark.php @@ -42,13 +56,13 @@ class TestPhpLanguageServers: # $greeting in echo $greeting; (e c h o $ g r e e t i n g) # ^ char 5 # Intelephense uses line 10 (0-indexed), Phpactor uses line 11 (0-indexed) - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 11, 6) else: definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 10, 6) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): assert len(definition_location_list) >= 1 else: assert len(definition_location_list) == 1 @@ -56,27 +70,27 @@ class TestPhpLanguageServers: assert definition_location["uri"].endswith("index.php") # Definition of $greeting is on line 10 (1-indexed) / line 9 (0-indexed), char 0 assert definition_location["range"]["start"]["line"] == 9 - if language_server.language_server.language != Language.PHP_PHPACTOR: + if not _is_non_default_php_backend(language_server): assert definition_location["range"]["start"]["character"] == 0 @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) def test_find_definition_across_files(self, language_server: SolidLanguageServer, repo_path: Path) -> None: # Intelephense uses line 12 (0-indexed), Phpactor uses line 13 (0-indexed) - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 13, 5) else: definition_location_list = language_server.request_definition(str(repo_path / "index.php"), 12, 5) assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): assert len(definition_location_list) >= 1 else: assert len(definition_location_list) == 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("helper.php") assert definition_location["range"]["start"]["line"] == 2 - if language_server.language_server.language != Language.PHP_PHPACTOR: + if not _is_non_default_php_backend(language_server): assert definition_location["range"]["start"]["character"] == 0 @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @@ -94,14 +108,14 @@ class TestPhpLanguageServers: definition_location_list = language_server.request_definition(file_path, 2, 6) # cursor on 'l' in $localVar assert definition_location_list, f"Expected non-empty definition_location_list but got {definition_location_list=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): assert len(definition_location_list) >= 1 else: assert len(definition_location_list) == 1 definition_location = definition_location_list[0] assert definition_location["uri"].endswith("simple_var.php") assert definition_location["range"]["start"]["line"] == 1 # Definition of $localVar (0-indexed) - if language_server.language_server.language != Language.PHP_PHPACTOR: + if not _is_non_default_php_backend(language_server): assert definition_location["range"]["start"]["character"] == 0 # $localVar (0-indexed) @pytest.mark.parametrize("language_server", _php_servers, indirect=True) @@ -117,7 +131,7 @@ class TestPhpLanguageServers: assert references, f"Expected non-empty references for $greeting but got {references=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): actual_locations = [ { "uri_suffix": loc["uri"].split("/")[-1], @@ -163,7 +177,7 @@ class TestPhpLanguageServers: assert references, f"Expected non-empty references for helperFunction but got {references=}" - if language_server.language_server.language == Language.PHP_PHPACTOR: + if _is_non_default_php_backend(language_server): actual_locations_comparable = [] for loc in references: actual_locations_comparable.append( @@ -194,7 +208,7 @@ class TestPhpLanguageServers: usage_in_index_php = {"uri_suffix": "index.php", "line": 13, "character": 0} assert usage_in_index_php in actual_locations_comparable, "Usage of helperFunction in index.php not found" - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: """Test that document symbols are properly retrieved after Intelephense capability fix.""" from solidlsp.ls_utils import SymbolUtils @@ -203,7 +217,7 @@ class TestPhpLanguageServers: assert SymbolUtils.symbol_tree_contains_name(symbols, "helperFunction"), "helperFunction not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet function not found in symbol tree" - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test that document symbols are properly retrieved for a specific file.""" doc_symbols = language_server.request_document_symbols("helper.php") @@ -211,7 +225,7 @@ class TestPhpLanguageServers: symbol_names = [sym.get("name") for sym in all_symbols[0] if sym.get("name")] assert "helperFunction" in symbol_names, f"helperFunction not found in document symbols. Found: {symbol_names}" - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_document_symbols_hierarchical_structure(self, language_server: SolidLanguageServer) -> None: """Verify Intelephense returns hierarchical DocumentSymbol format. @@ -245,7 +259,7 @@ class TestPhpLanguageServers: assert "greet" not in root_names, f"greet should be a child of Dog, not at root level. Roots: {root_names}" assert "fetch" not in root_names, f"fetch should be a child of Dog, not at root level. Roots: {root_names}" - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_full_symbol_tree_within_file(self, language_server: SolidLanguageServer) -> None: """Verify request_full_symbol_tree scoped to a PHP file returns correct symbols. diff --git a/test/solidlsp/php/test_php_diagnostics.py b/test/solidlsp/php/test_php_diagnostics.py index 1cfc67de..2bfead13 100644 --- a/test/solidlsp/php/test_php_diagnostics.py +++ b/test/solidlsp/php/test_php_diagnostics.py @@ -7,7 +7,7 @@ from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.php class TestPhpDiagnostics: - @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + @pytest.mark.parametrize("language_server", [Language.PHP, Language.PHP_PHPANTOM], indirect=True) def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: assert_file_diagnostics( language_server, diff --git a/test/solidlsp/php/test_phpantom.py b/test/solidlsp/php/test_phpantom.py new file mode 100644 index 00000000..4ca4a0b4 --- /dev/null +++ b/test/solidlsp/php/test_phpantom.py @@ -0,0 +1,231 @@ +import shutil +from pathlib import Path + +import pytest + +from serena.code_editor import LanguageServerCodeEditor +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from src.serena.symbol import LanguageServerSymbolRetriever +from test.conftest import project_with_ls_context, start_ls_context + + +def _extract_changes(workspace_edit: dict) -> dict[str, list[dict]]: + changes = workspace_edit.get("changes", {}) + if changes: + return changes + + document_changes = workspace_edit.get("documentChanges", []) + return {change["textDocument"]["uri"]: change["edits"] for change in document_changes if "textDocument" in change and "edits" in change} + + +def _copy_php_fixture(tmp_path: Path) -> Path: + from test.conftest import get_repo_path + + fixture_path = get_repo_path(Language.PHP) + target_path = tmp_path / "test_repo" + shutil.copytree(fixture_path, target_path) + return target_path + + +def _write_psr4_fixture(repo_path: Path) -> None: + serena_dir = repo_path / ".serena" + serena_dir.mkdir(exist_ok=True) + (serena_dir / "project.yml").write_text( + """project_name: php-phase1 +languages: + - php_phpantom +""", + encoding="utf-8", + ) + + (repo_path / "composer.json").write_text('{"autoload": {"psr-4": {"Demo\\\\": "src/"}}}\n', encoding="utf-8") + + src_dir = repo_path / "src" + src_dir.mkdir(exist_ok=True) + + (src_dir / "Greeter.php").write_text( + """<?php +namespace Demo; + +interface Greeter +{ + /** Returns a greeting for the given name. */ + public function greet(string $name): string; +} +""", + encoding="utf-8", + ) + + (src_dir / "Helper.php").write_text( + """<?php +namespace Demo; + +/** Utility helper docs. */ +function format_name(string $name): string +{ + return strtoupper($name); +} +""", + encoding="utf-8", + ) + + (src_dir / "Welcome.php").write_text( + """<?php +namespace Demo; + +use const Demo\\MAX_GREETING_LENGTH; +use function Demo\\format_name; + +const MAX_GREETING_LENGTH = 80; + +/** Welcome service docs. */ +class Welcome implements Greeter +{ + /** Friendly greeting docs. */ + public function greet(string $name): string + { + return substr("Hello ".format_name($name), 0, MAX_GREETING_LENGTH); + } +} +""", + encoding="utf-8", + ) + + (src_dir / "UseWelcome.php").write_text( + """<?php +namespace Demo; + +function run_demo(): string +{ + $service = new Welcome(); + return $service->greet('world'); +} +""", + encoding="utf-8", + ) + + +def _find_root_symbol(language_server: SolidLanguageServer, relative_path: str, symbol_name: str) -> dict: + all_symbols, root_symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots() + for symbol in root_symbols: + if symbol.get("name") == symbol_name: + return symbol + for symbol in all_symbols: + if symbol.get("name") == symbol_name: + return symbol + raise AssertionError(f"Symbol {symbol_name!r} not found in {relative_path}") + + +def _find_child_symbol(parent_symbol: dict, child_name: str) -> dict: + for child in parent_symbol.get("children", []): + if child.get("name") == child_name: + return child + raise AssertionError(f"Child symbol {child_name!r} not found in {parent_symbol.get('name')!r}") + + +@pytest.mark.php +class TestPHPantom: + @pytest.mark.parametrize("language_server", [Language.PHP_PHPANTOM], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True) + def test_rename_local_variable(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + workspace_edit = language_server.request_rename_symbol_edit(str(Path("index.php")), 9, 1, "welcomeMessage") + assert workspace_edit is not None, "Rename should be supported for local PHP variables" + + changes = _extract_changes(workspace_edit) + index_edits = [edits for uri, edits in changes.items() if uri.endswith("index.php")] + assert index_edits, f"Expected edits for index.php, got: {list(changes.keys())}" + + edits = index_edits[0] + assert len(edits) >= 2, f"Expected at least two local variable edits, got {len(edits)}" + assert {edit["range"]["start"]["line"] for edit in edits} >= {9, 11} + assert all(edit["newText"] == "$welcomeMessage" for edit in edits) + + def test_rename_method_across_files(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + welcome_symbol = _find_root_symbol(language_server, "src/Welcome.php", "Welcome") + greet_symbol = _find_child_symbol(welcome_symbol, "greet") + selection = greet_symbol["selectionRange"]["start"] + workspace_edit = language_server.request_rename_symbol_edit( + "src/Welcome.php", selection["line"], selection["character"], "salute" + ) + + assert workspace_edit is not None, "Rename should be supported for methods" + changes = _extract_changes(workspace_edit) + changed_files = sorted(uri.split("/")[-1] for uri in changes) + assert "Welcome.php" in changed_files + assert "UseWelcome.php" in changed_files + assert all(edit["newText"] == "salute" for edits in changes.values() for edit in edits) + + def test_rename_class_across_files(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + welcome_symbol = _find_root_symbol(language_server, "src/Welcome.php", "Welcome") + selection = welcome_symbol["selectionRange"]["start"] + workspace_edit = language_server.request_rename_symbol_edit( + "src/Welcome.php", selection["line"], selection["character"], "GreetingService" + ) + + assert workspace_edit is not None, "Rename should be supported for classes" + changes = _extract_changes(workspace_edit) + changed_files = sorted(uri.split("/")[-1] for uri in changes) + assert "GreetingService.php" in changed_files or "Welcome.php" in changed_files + assert "UseWelcome.php" in changed_files + assert all(edit["newText"] == "GreetingService" for edits in changes.values() for edit in edits) + + def test_psr4_class_rename_applies_file_rename(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with project_with_ls_context(Language.PHP_PHPANTOM, str(repo_path)) as project: + symbol_retriever = LanguageServerSymbolRetriever(project) + code_editor = LanguageServerCodeEditor(symbol_retriever) + status_message = code_editor.rename_symbol("Welcome", relative_path="src/Welcome.php", new_name="GreetingService") + + assert "Successfully renamed 'Welcome' to 'GreetingService'" in status_message + assert not (repo_path / "src" / "Welcome.php").exists() + assert (repo_path / "src" / "GreetingService.php").exists() + + renamed_content = (repo_path / "src" / "GreetingService.php").read_text(encoding="utf-8") + usage_content = (repo_path / "src" / "UseWelcome.php").read_text(encoding="utf-8") + assert "class GreetingService" in renamed_content + assert "new GreetingService()" in usage_content + + def test_hover_info_is_available_via_find_symbol_include_info(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with project_with_ls_context(Language.PHP_PHPANTOM, str(repo_path)) as project: + symbol_retriever = LanguageServerSymbolRetriever(project) + symbols = symbol_retriever.find("Welcome", within_relative_path="src/Welcome.php") + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(symbols) + + welcome_infos = [info for symbol, info in info_by_symbol.items() if symbol.name == "Welcome"] + assert welcome_infos, "Expected Welcome symbol info to be available" + assert any(info and "Welcome service docs." in info for info in welcome_infos) + + def test_workspace_symbol_queries_cover_class_function_and_constant(self, tmp_path: Path) -> None: + repo_path = _copy_php_fixture(tmp_path) + _write_psr4_fixture(repo_path) + + with start_ls_context(Language.PHP_PHPANTOM, repo_path=str(repo_path), solidlsp_dir=tmp_path) as language_server: + class_symbols = language_server.request_workspace_symbol("Welcome") or [] + function_symbols = language_server.request_workspace_symbol("format_name") or [] + constant_symbols = language_server.request_workspace_symbol("MAX_GREETING_LENGTH") or [] + + def summarize(symbols: list[dict]) -> list[tuple[str, str]]: + return [(symbol["name"], symbol["location"]["uri"]) for symbol in symbols] + + assert any(name == "Demo\\Welcome" and uri.endswith("src/Welcome.php") for name, uri in summarize(class_symbols)) + assert all(uri.endswith(".php") for _name, uri in summarize(function_symbols)) + assert function_symbols == [] or any( + "location" in symbol and symbol["location"]["uri"].startswith("file://") for symbol in function_symbols + ) + assert constant_symbols == [] or any( + "location" in symbol and symbol["location"]["uri"].startswith("file://") for symbol in constant_symbols + ) From 83d19905d0ef451073b4d9eb8f7356499f58c4da Mon Sep 17 00:00:00 2001 From: TiM <tim.vdh@gmail.com> Date: Mon, 29 Jun 2026 00:48:55 +1200 Subject: [PATCH 041/170] solidlsp: recognize Arduino .ino files as C++ (#1534) --- src/solidlsp/ls_config.py | 7 +++++++ test/solidlsp/cpp/test_cpp_basic.py | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 9d36b453..8bda4dc9 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -361,6 +361,11 @@ class Language(str, Enum): # OpenCL ".cl", ".clcpp", + # Arduino sketch: not in clang's extension table, but it is C++. + # Routing it here lets clangd serve symbols; the project must + # tell clangd it is C++ (a .clangd with CompileFlags Add: [-xc++], + # or a compile DB), since the clang driver can't infer a job from .ino. + ".ino", case_sensitive=False, ) case self.CPP_CCLS: @@ -386,6 +391,8 @@ class Language(str, Enum): # Objective-C ".m", ".mm", + # Arduino sketch (C++); see note in the CPP case above. + ".ino", case_sensitive=False, ) case self.KOTLIN: diff --git a/test/solidlsp/cpp/test_cpp_basic.py b/test/solidlsp/cpp/test_cpp_basic.py index 21b90452..749efcac 100644 --- a/test/solidlsp/cpp/test_cpp_basic.py +++ b/test/solidlsp/cpp/test_cpp_basic.py @@ -24,6 +24,20 @@ if language_tests_enabled(Language.CPP_CCLS): _cpp_servers.append(Language.CPP_CCLS) +@pytest.mark.parametrize("language", [Language.CPP, Language.CPP_CCLS]) +def test_source_fn_matcher_includes_ino(language: Language) -> None: + """Arduino .ino sketches are C++ and must route to the C++ language server. + + This is a pure matcher check; it needs no running language server. + """ + matcher = language.get_source_fn_matcher() + assert matcher.is_relevant_filename("sketch.ino") + assert matcher.is_relevant_filename("BLINK.INO") # case-insensitive + assert matcher.is_relevant_filename("/path/to/s3_camera.ino") + assert matcher.is_relevant_filename("main.cpp") # regression: ordinary C++ still matches + assert not matcher.is_relevant_filename("notes.md") + + @pytest.mark.cpp @pytest.mark.skipif(not _cpp_servers, reason="No C++ language server (clangd or ccls) available") class TestCppLanguageServer: From 6e7f0e648d48f7b1409cf6c4dd3231d67b7714cb Mon Sep 17 00:00:00 2001 From: wangzhe <0x1998@gmail.com> Date: Wed, 29 Apr 2026 16:30:06 +0800 Subject: [PATCH 042/170] Add pyrefly as alternative lsp for python --- docs/01-about/020_programming-languages.md | 5 + .../language_servers/pyrefly_server.py | 303 ++++++++++++++++++ src/solidlsp/ls_config.py | 9 +- test/conftest.py | 2 + .../repos/python/test_repo/pyrefly.toml | 0 test/solidlsp/conftest.py | 2 +- 6 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 src/solidlsp/language_servers/pyrefly_server.py create mode 100644 test/resources/repos/python/test_repo/pyrefly.toml diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index a6c0e133..d827971f 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -125,6 +125,11 @@ Some languages require additional installations or setup steps, as noted. we also support [Phpactor](https://github.com/phpactor/phpactor) (language `php_phpactor`), which requires PHP 8.1+; and the experimental [PHPantom](https://github.com/PHPantom-dev/phpantom_lsp) backend (language `php_phpantom`) * **Python** + (by default, uses [Pyright](https://github.com/microsoft/pyright) (language `python`); + alternatives: [ty](https://github.com/astral-sh/ty) (language `python_ty`), + [pyrefly](https://github.com/facebook/pyrefly) (language `python_pyrefly`), + [Jedi](https://github.com/palotas/jedi-language-server) (language `python_jedi`); + ty and pyrefly require `uv`/`uvx` in PATH) * **R** (requires installation of the `languageserver` R package) * **Ruby** diff --git a/src/solidlsp/language_servers/pyrefly_server.py b/src/solidlsp/language_servers/pyrefly_server.py new file mode 100644 index 00000000..3c5f26e2 --- /dev/null +++ b/src/solidlsp/language_servers/pyrefly_server.py @@ -0,0 +1,303 @@ +""" +Python language server integration using Meta's ``pyrefly``. + +You can pass the following entries in ``ls_specific_settings["python_pyrefly"]``: + - ls_path: Override the executable used to start ``pyrefly``. + - pyrefly_version: Override the pinned ``pyrefly`` version used with ``uvx`` / ``uv x`` + (default: the bundled Serena version). + - indexing_mode: Override pyrefly's LSP indexing mode (e.g. ``lazy-blocking``). + - workspace_indexing_limit: Override pyrefly's workspace indexing limit. +""" + +import logging +import os +import pathlib +import sys +import threading +import time +from typing import Any, cast + +from typing_extensions import override + +from solidlsp import ls_types +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, LSPErrorCodes +from solidlsp.lsp_protocol_handler.server import LSPError, PayloadLike +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +PYREFLY_VERSION = "1.1.1" +PYREFLY_CONFIG_DOC_URL = "https://pyrefly.org/en/docs/configuration/" + +# Pyrefly cancels in-flight requests with these error codes whenever its workspace state mutates +# (e.g. background re-indexing triggered by opening files). Such cancellations are transient. +_MUTATION_RETRY_ERROR_CODES = (LSPErrorCodes.RequestCancelled, LSPErrorCodes.ContentModified) + + +class PyreflyLanguageServer(SolidLanguageServer): + """ + Provides Python specific instantiation of the LanguageServer class using ``pyrefly``. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a PyreflyLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + super().__init__( + config, + repository_root_path, + None, + str(config.code_language), + solidlsp_settings, + ) + self._ensure_workspace_pyrefly_config() + self._indexing_complete = threading.Event() + self._active_progress_tokens: set[Any] = set() + + def _ensure_workspace_pyrefly_config(self) -> None: + """ + Ensure pyrefly has a stable workspace boundary by creating an empty ``pyrefly.toml`` when + neither ``pyrefly.toml`` nor ``pyproject.toml`` exists in the repository root. + """ + if not os.path.isdir(self.repository_root_path): + return + + pyrefly_config_path = os.path.join(self.repository_root_path, "pyrefly.toml") + pyproject_config_path = os.path.join(self.repository_root_path, "pyproject.toml") + + if not os.path.exists(pyrefly_config_path) and not os.path.exists(pyproject_config_path): + try: + pathlib.Path(pyrefly_config_path).touch() + log.warning( + "No config found in repository root (%s): neither `pyrefly.toml` nor `pyproject.toml` exists. " + "Created empty `%s` as a fallback so pyrefly won't search parent directories and mis-resolve imports. " + "See pyrefly config docs: %s", + self.repository_root_path, + pyrefly_config_path, + PYREFLY_CONFIG_DOC_URL, + ) + except OSError as exc: + log.warning("Failed to create fallback pyrefly config at %s: %s", pyrefly_config_path, exc) + + def _install_mutation_retry(self, max_retries: int = 5, retry_delay: float = 0.2) -> None: + """ + Wraps the underlying ``send_request`` so that requests canceled by pyrefly due to a + concurrent workspace mutation are transparently retried. + + Pyrefly performs background re-indexing (e.g. when files are opened) and cancels any + in-flight request with ``RequestCancelled`` (-32800) or ``ContentModified`` (-32801) + while that mutation is in progress. These cancellations are transient, so we re-issue + the request after a short delay instead of surfacing the error to the caller. + """ + inner_send_request = self.server.send_request + + def send_request_with_retry(method: str, params: dict | None = None) -> PayloadLike: + last_exc: SolidLSPException | None = None + for attempt in range(1, max_retries + 1): + try: + return inner_send_request(method, params) + except SolidLSPException as e: + cause = e.cause + if not (isinstance(cause, LSPError) and int(cause.code) in _MUTATION_RETRY_ERROR_CODES): + raise + last_exc = e + log.info( + "Pyrefly request %s canceled by concurrent mutation (code=%s); retrying (%d/%d)", + method, + cause.code, + attempt, + max_retries, + ) + time.sleep(retry_delay) + assert last_exc is not None + raise last_exc + + self.server.send_request = send_request_with_retry # type: ignore[method-assign] + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + extra_args = ["lsp"] + indexing_mode = self._custom_settings.get("indexing_mode") + if indexing_mode is not None: + extra_args.extend(["--indexing-mode", str(indexing_mode)]) + workspace_indexing_limit = self._custom_settings.get("workspace_indexing_limit") + if workspace_indexing_limit is not None: + extra_args.extend(["--workspace-indexing-limit", str(workspace_indexing_limit)]) + return LanguageServerDependencyProviderUvx( + self._custom_settings, + self._ls_resources_dir, + package="pyrefly", + entrypoint="pyrefly", + default_version=PYREFLY_VERSION, + version_setting_key="pyrefly_version", + extra_args=extra_args, + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] + + @override + def request_defining_symbol( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> ls_types.UnifiedSymbolInformation | None: + symbol = super().request_defining_symbol(relative_file_path, line, column, include_body=include_body) + if symbol is None or symbol.get("name") != "__init__": + return symbol + + # Pyrefly resolves a constructor call (e.g. ``User(...)``) to the class's ``__init__`` method, + # whereas jedi/pyright resolve it to the class itself. For parity, return the enclosing class + # unless the caller pointed directly at the ``__init__`` definition. + location = symbol.get("location") + if location is not None: + def_path = location.get("relativePath") + def_line = location.get("range", {}).get("start", {}).get("line") + if (def_path, def_line) == (relative_file_path, line): + return symbol + + enclosing = self.request_container_of_symbol(symbol, include_body=include_body) + if enclosing is not None and enclosing.get("kind") == ls_types.SymbolKind.Class: + return enclosing + return symbol + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + return "python" + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the pyrefly language server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "clientInfo": {"name": "Serena", "version": "0.1.0"}, + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "window": { + "workDoneProgress": True, + }, + "workspace": { + "workspaceEdit": {"documentChanges": True}, + "configuration": True, + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + "didSave": True, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + }, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, + }, + }, + "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], + } + return cast(InitializeParams, initialize_params) + + def _start_server(self) -> None: + """ + Starts the pyrefly language server and waits for initial workspace indexing to complete. + + Pyrefly defers indexing until it receives a workspace/configuration response, + then reports progress via $/progress notifications. + """ + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info("LSP: window/logMessage: %s", msg.get("message", "")) + + def workspace_configuration_handler(params: dict) -> list[dict]: + items = params.get("items", []) if isinstance(params, dict) else [] + log.info("Pyrefly workspace/configuration request received: %d items", len(items)) + config = { + "pythonPath": sys.executable, + "pyrefly": { + # Ensure workspace-wide analysis parity with VSCode's project mode behavior. + "diagnosticMode": "workspace", + }, + } + return [config.copy() for _ in items] + + def work_done_progress_create(params: dict) -> None: + return + + def on_progress(params: dict) -> None: + token = params.get("token") + value = params.get("value", {}) + kind = value.get("kind") + if kind == "begin": + self._active_progress_tokens.add(token) + log.info("Pyrefly progress begin: %s (token=%s)", value.get("title", ""), token) + elif kind == "end": + self._active_progress_tokens.discard(token) + log.info("Pyrefly progress end (token=%s)", token) + if not self._active_progress_tokens: + self._indexing_complete.set() + + # setting up handlers + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + self.server.on_request("window/workDoneProgress/create", work_done_progress_create) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", on_progress) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + # starting and initializing the server + log.info("Starting pyrefly language server process") + self.server.start() + # retry requests that pyrefly cancels while re-indexing the workspace in the background + self._install_mutation_retry() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to pyrefly server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.info("Received initialize response from pyrefly server: %s", init_response) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities + assert "definitionProvider" in capabilities + assert "referencesProvider" in capabilities + assert "documentSymbolProvider" in capabilities + + # completing the initialization handshake + self.server.notify.initialized({}) + + # wait for pyrefly to finish initial workspace indexing + log.info("Waiting for pyrefly to complete initial workspace indexing...") + if self._indexing_complete.wait(timeout=30.0): + log.info("Pyrefly initial indexing complete, server ready") + else: + log.warning("Timeout waiting for pyrefly indexing completion, proceeding anyway") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 8bda4dc9..b58f056c 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -142,6 +142,8 @@ class Language(str, Enum): """Jedi language server for Python (instead of pyright, which is the default)""" PYTHON_TY = "python_ty" """Ty language server for Python (instead of pyright, which is the default).""" + PYTHON_PYREFLY = "python_pyrefly" + """Pyrefly language server for Python (instead of pyright, which is the default).""" CSHARP_OMNISHARP = "csharp_omnisharp" """OmniSharp language server for C# (instead of the default csharp-ls by microsoft). Currently has problems with finding references, and generally seems less stable and performant. @@ -253,6 +255,7 @@ class Language(str, Enum): self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.PYTHON_TY, + self.PYTHON_PYREFLY, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.PHP_PHPACTOR, @@ -304,7 +307,7 @@ class Language(str, Enum): def get_source_fn_matcher(self) -> FilenameMatcher: match self: - case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY: + case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY: return FilenameMatcher(".py", ".pyi") case self.JAVA: return FilenameMatcher(".java") @@ -547,6 +550,10 @@ class Language(str, Enum): from solidlsp.language_servers.ty_server import TyLanguageServer return TyLanguageServer + case self.PYTHON_PYREFLY: + from solidlsp.language_servers.pyrefly_server import PyreflyLanguageServer + + return PyreflyLanguageServer case self.JAVA: from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS diff --git a/test/conftest.py b/test/conftest.py index eb826617..e342daf8 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -46,6 +46,7 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.PYTHON_TY: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, Language.PYTHON_TY: Language.PYTHON, + Language.PYTHON_PYREFLY: Language.PYTHON, } PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY] @@ -266,6 +267,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.PYTHON: [pytest.mark.python], Language.PYTHON_JEDI: [pytest.mark.python], Language.PYTHON_TY: [pytest.mark.python], + Language.PYTHON_PYREFLY: [pytest.mark.python], Language.RUST: [pytest.mark.rust], Language.TYPESCRIPT: [pytest.mark.typescript], Language.BSL: [ diff --git a/test/resources/repos/python/test_repo/pyrefly.toml b/test/resources/repos/python/test_repo/pyrefly.toml new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py index 79c04617..6586387c 100644 --- a/test/solidlsp/conftest.py +++ b/test/solidlsp/conftest.py @@ -4,7 +4,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation -PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY] +PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY, Language.PYTHON_PYREFLY] def read_repo_file(language_server: SolidLanguageServer, relative_path: str) -> str: From ae097c5c10819eab69c96a25a460946f9dbb8e99 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 13:48:39 +0200 Subject: [PATCH 043/170] Remove perl and nix from CI tests (heavy toolchains) --- .github/workflows/pytest.yml | 42 --------------------------- test/solidlsp/nix/test_nix_basic.py | 5 +--- test/solidlsp/perl/test_perl_basic.py | 4 +-- 3 files changed, 3 insertions(+), 48 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 9cf5535b..6b822f51 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -296,23 +296,6 @@ jobs: echo "$LUA_LS_DIR/bin" >> $GITHUB_PATH rm lua-ls.zip fi - - name: Install Perl::LanguageServer - if: runner.os != 'Windows' - shell: bash - run: | - if [[ "${{ runner.os }}" == "Linux" ]]; then - sudo apt-get update - sudo apt-get install -y cpanminus build-essential libanyevent-perl libio-aio-perl - elif [[ "${{ runner.os }}" == "macOS" ]]; then - brew install cpanminus - fi - PERL_MM_USE_DEFAULT=1 cpanm --notest --force Perl::LanguageServer - # Set up Perl local::lib environment for subsequent steps - echo "PERL5LIB=$HOME/perl5/lib/perl5${PERL5LIB:+:${PERL5LIB}}" >> $GITHUB_ENV - echo "PERL_LOCAL_LIB_ROOT=$HOME/perl5${PERL_LOCAL_LIB_ROOT:+:${PERL_LOCAL_LIB_ROOT}}" >> $GITHUB_ENV - echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV - echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV - echo "$HOME/perl5/bin" >> $GITHUB_PATH - name: Install ansible-core and ansible-lint (for Ansible language server tests) shell: bash run: uv run pip install ansible-core ansible-lint @@ -325,31 +308,6 @@ jobs: - name: Install Elm shell: bash run: npm install -g elm@0.19.1-6 - - name: Install Nix - if: runner.os != 'Windows' # Nix doesn't support Windows natively - uses: cachix/install-nix-action@v30 - with: - nix_path: nixpkgs=channel:nixos-unstable - - name: Install nixd (Nix Language Server) - if: runner.os != 'Windows' # Skip on Windows since Nix isn't available - shell: bash - run: | - # Install nixd using nix - nix profile install github:nix-community/nixd - - # Verify nixd is installed and working - if ! command -v nixd &> /dev/null; then - echo "nixd installation failed or not in PATH" - exit 1 - fi - - echo "$HOME/.nix-profile/bin" >> $GITHUB_PATH - - name: Verify Nix package build - if: runner.os != 'Windows' # Nix only supported on Linux/macOS - shell: bash - run: | - # Verify the flake builds successfully - nix build --no-link - name: Install Regal (Rego Language Server) shell: bash run: | diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index e66ddfa5..61207833 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -4,8 +4,6 @@ Tests for the Nix language server implementation using nixd. These tests validate symbol finding and cross-file reference capabilities for Nix expressions. """ -import platform - import pytest from solidlsp import SolidLanguageServer @@ -15,8 +13,7 @@ from test.conftest import is_ci from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -# Skip all Nix tests on Windows as Nix doesn't support Windows -pytestmark = pytest.mark.skipif(platform.system() == "Windows", reason="Nix and nil are not available on Windows") +pytestmark = pytest.mark.skip("Nix is niche and has a heavy toolchain") @pytest.mark.nix diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py index 9ba29cfb..1aa185f4 100644 --- a/test/solidlsp/perl/test_perl_basic.py +++ b/test/solidlsp/perl/test_perl_basic.py @@ -1,4 +1,3 @@ -import platform from pathlib import Path import pytest @@ -8,9 +7,10 @@ from solidlsp.ls_config import Language from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics +pytestmark = pytest.mark.skip("Perl is niche and has a heavy toolchain") + @pytest.mark.perl -@pytest.mark.skipif(platform.system() == "Windows", reason="Perl::LanguageServer does not support native Windows operation") class TestPerlLanguageServer: """ Tests for Perl::LanguageServer integration. From 01434796212bb1856e7e418a3d854710bf544e52 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 16:27:07 +0200 Subject: [PATCH 044/170] Tests: centralized test enabling logic, better parallelization in CI Removing some heavy toolchains from windows/macos run (they will be tested only in the job based off the maximal docker image) --- .github/workflows/pytest.yml | 254 ++++++++++++------ test/conftest.py | 194 ++++++++++--- test/serena/test_serena_agent.py | 9 +- test/serena/test_symbol_editing.py | 5 +- test/solidlsp/ansible/test_ansible_basic.py | 4 +- test/solidlsp/bsl/test_bsl_basic.py | 3 +- test/solidlsp/bsl/test_bsl_diagnostics.py | 5 +- test/solidlsp/crystal/test_crystal_basic.py | 4 +- .../crystal/test_crystal_diagnostics.py | 5 +- test/solidlsp/elixir/test_elixir_basic.py | 5 +- .../elixir/test_elixir_ignored_dirs.py | 6 +- .../elixir/test_elixir_integration.py | 5 +- .../elixir/test_elixir_symbol_retrieval.py | 5 +- test/solidlsp/elm/test_elm_basic.py | 5 +- test/solidlsp/erlang/test_erlang_basic.py | 5 +- .../erlang/test_erlang_ignored_dirs.py | 6 +- .../erlang/test_erlang_symbol_retrieval.py | 5 +- test/solidlsp/fsharp/test_fsharp_basic.py | 17 +- test/solidlsp/haskell/test_haskell_basic.py | 6 +- test/solidlsp/julia/test_julia_basic.py | 5 +- test/solidlsp/kotlin/test_kotlin_basic.py | 5 +- test/solidlsp/lean4/test_lean4_basic.py | 3 + test/solidlsp/lean4/test_lean4_diagnostics.py | 3 + test/solidlsp/matlab/test_matlab_basic.py | 18 +- test/solidlsp/nix/test_nix_basic.py | 4 +- test/solidlsp/ocaml/test_cross_file_refs.py | 2 + test/solidlsp/ocaml/test_ocaml_basic.py | 4 +- test/solidlsp/ocaml/test_ocaml_diagnostics.py | 5 +- test/solidlsp/perl/test_perl_basic.py | 5 +- test/solidlsp/r/test_r_basic.py | 4 +- test/solidlsp/rego/test_rego_basic.py | 5 +- test/solidlsp/swift/test_swift_basic.py | 9 +- .../systemverilog/test_systemverilog_basic.py | 7 +- .../test_systemverilog_diagnostics.py | 5 + .../terraform/test_terraform_basic.py | 6 +- 35 files changed, 408 insertions(+), 230 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6b822f51..3aa59854 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -15,13 +15,37 @@ concurrency: jobs: cpu: - name: Tests on ${{ matrix.os }} + name: ${{ matrix.batch }} (${{ matrix.os }}) runs-on: ${{ matrix.os }} + # Marker groups are defined once here and combined in the "Compute marker expression" step. + # The catch-all batch is derived as the negation of all named groups, so adding a language to + # any group automatically removes it from catch-all -- the partition can never desync, and no + # test can fall through to "no batch". To add/rebalance: edit a MARKERS_* var, and (for a new + # group) add a case branch below + the batch name to the matrix list. + env: + MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl" + MARKERS_NATIVE: "cpp or rust or go or zig or pascal" + MARKERS_SWIFT: "swift" # split out: swiftly toolchain install is the slowest setup, isolate + cache it + MARKERS_WEB: "typescript or svelte or vue or angular or html or scss or json or solidity or elm" + MARKERS_PYTHON: "python" + MARKERS_SCRIPTING: "ruby or php or perl or lua or luau or bash or powershell or elixir or erlang or dart or haxe" + MARKERS_OTHER_LANGS: "terraform or rego or ansible or yaml or toml or markdown or latex or nix or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al or ocaml or haskell or lean4 or julia or r" strategy: fail-fast: false matrix: os: [windows-latest, macos-latest] python-version: ["3.11"] + # Test execution is split into parallel batches by pytest marker. Each batch installs ONLY + # the toolchains its languages need -- steps below are gated with `if: matrix.batch == ...` + # -- so e.g. Swift/Lean are set up only in the batch that runs their tests, not in all 7. + # Node/Python/uv stay in every batch (cheap, and needed for the venv + auto-installed LSes). + # "catch-all" carries the unmarked core-framework tests in test/serena and needs no language + # toolchains. Rebalance once per-batch timings show up. + batch: [jvm, native, swift, web, python, scripting, other-langs, catch-all] + # Swift only runs on macOS (disabled on Windows via the central guard), so skip windows x swift. + exclude: + - os: windows-latest + batch: swift steps: - uses: actions/checkout@v4 - name: Free disk space @@ -41,6 +65,7 @@ jobs: with: python-version: "${{ matrix.python-version }}" - uses: actions/setup-go@v5 + if: matrix.batch == 'native' with: go-version: ">=1.17.0" - name: Set up Node.js @@ -54,8 +79,10 @@ jobs: mkdir -p $HOME/.cache/go-build mkdir -p $HOME/go/bin - name: Install uv - shell: bash - run: curl -LsSf https://astral.sh/uv/install.sh | sh + # Use the official action (not `curl | sh`): it adds uv to GITHUB_PATH (the real OS PATH), + # so the Python language server's subprocess `shutil.which("uv"/"uvx")` finds it on Windows. + # The bash installer only puts uv on the Git-Bash MSYS PATH, invisible to Windows processes. + uses: astral-sh/setup-uv@v5 - name: Cache uv virtualenv id: cache-uv uses: actions/cache@v3 @@ -75,12 +102,15 @@ jobs: shell: bash run: uv pip list - name: Check formatting + # Lint is batch-independent; run it only once per OS instead of in every batch. + if: matrix.batch == 'catch-all' shell: bash run: uv run poe lint # Add Go bin directory to PATH for this workflow # GITHUB_PATH is a special file that GitHub Actions uses to modify PATH # Writing to this file adds the directory to the PATH for subsequent steps - name: Cache Go binaries + if: matrix.batch == 'native' id: cache-go-binaries uses: actions/cache@v3 with: @@ -89,7 +119,7 @@ jobs: ~/.cache/go-build key: go-binaries-${{ runner.os }}-gopls-latest - name: Install gopls - if: steps.cache-go-binaries.outputs.cache-hit != 'true' + if: matrix.batch == 'native' && steps.cache-go-binaries.outputs.cache-hit != 'true' shell: bash run: go install golang.org/x/tools/gopls@latest # Erlang currently not tested in CI, random hangings on macos, always hangs on ubuntu @@ -107,10 +137,12 @@ jobs: # # Ensure erlang_ls is in PATH # echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Install clojure tools + if: matrix.batch == 'jvm' uses: DeLaGuardo/setup-clojure@13.4 with: cli: latest - name: Install ccls (C/C++ Language Server) + if: matrix.batch == 'native' shell: bash run: | if [[ "${{ runner.os }}" == "Linux" ]]; then @@ -129,18 +161,22 @@ jobs: exit 1 fi - name: Setup Java (for JVM based languages) + if: matrix.batch == 'jvm' uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '21' - name: Setup .NET SDK (for F# and C# languages) + if: matrix.batch == 'jvm' uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - name: List .NET runtimes + if: matrix.batch == 'jvm' shell: bash run: dotnet --list-runtimes - name: Install Terraform + if: matrix.batch == 'other-langs' uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.5.0" @@ -151,25 +187,46 @@ jobs: # Installation of swift with the action screws with installation of ruby on macOS for some reason # We can try again when version 3 of the action is released, where they will also use swiftly # Until then, we use custom code to install swift. Sourcekit-lsp is installed automatically with swift + - name: Cache Swift toolchain (swiftly) + if: runner.os == 'macOS' && matrix.batch == 'swift' + id: cache-swiftly + uses: actions/cache@v3 + with: + # macOS keeps the toolchain (incl. sourcekit-lsp) under ~/Library/Developer/Toolchains, + # NOT under ~/.swiftly -- both must be cached or the toolchain is lost on restore. + path: | + ~/.swiftly + ~/Library/Developer/Toolchains + key: swiftly-${{ runner.os }}-6.1.2-v2 - name: Install Swift with swiftly (macOS) - if: runner.os == 'macOS' + if: runner.os == 'macOS' && matrix.batch == 'swift' run: | - echo "=== Installing swiftly on macOS ===" - curl -O https://download.swift.org/swiftly/darwin/swiftly.pkg && \ - installer -pkg swiftly.pkg -target CurrentUserHomeDirectory && \ - ~/.swiftly/bin/swiftly init --quiet-shell-followup && \ - . "${SWIFTLY_HOME_DIR:-$HOME/.swiftly}/env.sh" && \ + set -e + # Install swiftly itself only if its binary isn't already present (e.g. restored from cache). + if [ ! -x "$HOME/.swiftly/bin/swiftly" ]; then + echo "=== Installing swiftly ===" + curl -O https://download.swift.org/swiftly/darwin/swiftly.pkg + installer -pkg swiftly.pkg -target CurrentUserHomeDirectory + "$HOME/.swiftly/bin/swiftly" init --quiet-shell-followup + fi + . "${SWIFTLY_HOME_DIR:-$HOME/.swiftly}/env.sh" hash -r - swiftly install --use 6.1.2 - swiftly use 6.1.2 - echo "~/.swiftly/bin" >> $GITHUB_PATH - echo "Swiftly installed successfully" - # Verify sourcekit-lsp is working before proceeding - echo "=== Verifying sourcekit-lsp installation ===" - which sourcekit-lsp || echo "Warning: sourcekit-lsp not found in PATH" - sourcekit-lsp --help || echo "Warning: sourcekit-lsp not responding" + # Always run install: it downloads on a cache miss and is a fast no-op when the toolchain is + # genuinely present from cache. No `--use` -- that triggers the interactive .swift-version prompt. + swiftly install 6.1.2 + # Locate sourcekit-lsp directly (macOS toolchains live under ~/Library/Developer/Toolchains) + # and put its directory on PATH -- avoids `swiftly use`, which prompts/hangs in CI. + SK="$(find "$HOME/Library/Developer/Toolchains" "$HOME/.swiftly" -name sourcekit-lsp 2>/dev/null | head -1)" + if [ -z "$SK" ]; then + echo "ERROR: sourcekit-lsp not found after Swift install. Searched:" + ls -la "$HOME/Library/Developer/Toolchains" 2>/dev/null || echo " (no ~/Library/Developer/Toolchains)" + ls -la "$HOME/.swiftly" 2>/dev/null || echo " (no ~/.swiftly)" + exit 1 + fi + echo "sourcekit-lsp: $SK" + dirname "$SK" >> "$GITHUB_PATH" - name: Install Swift with swiftly (Ubuntu) - if: runner.os == 'Linux' + if: runner.os == 'Linux' && matrix.batch == 'swift' run: | echo "=== Installing swiftly on Ubuntu ===" # Install dependencies BEFORE Swift to avoid exit code 1 @@ -190,38 +247,48 @@ jobs: which sourcekit-lsp || echo "Warning: sourcekit-lsp not found in PATH" sourcekit-lsp --help || echo "Warning: sourcekit-lsp not responding" - name: Install Ruby + if: matrix.batch == 'scripting' uses: ruby/setup-ruby@v1 with: ruby-version: '3.4' - name: Install Ruby language server + if: matrix.batch == 'scripting' shell: bash run: gem install ruby-lsp - name: Install Zig + if: matrix.batch == 'native' uses: mlugg/setup-zig@v2 with: version: 0.14.1 + - name: Cache ZLS (Zig Language Server) + if: matrix.batch == 'native' + id: cache-zls + uses: actions/cache@v3 + with: + path: ~/.zls + key: zls-${{ runner.os }}-0.14.0 - name: Install ZLS (Zig Language Server) + if: matrix.batch == 'native' shell: bash run: | - if [[ "${{ runner.os }}" == "Linux" ]]; then - wget https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-linux.tar.xz - tar -xf zls-x86_64-linux.tar.xz - sudo mv zls /usr/local/bin/ - rm zls-x86_64-linux.tar.xz - elif [[ "${{ runner.os }}" == "macOS" ]]; then - wget https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-macos.tar.xz - tar -xf zls-x86_64-macos.tar.xz - sudo mv zls /usr/local/bin/ - rm zls-x86_64-macos.tar.xz - elif [[ "${{ runner.os }}" == "Windows" ]]; then - curl -L -o zls.zip https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-windows.zip - unzip -o zls.zip - mkdir -p "$HOME/bin" - mv zls.exe "$HOME/bin/" - echo "$HOME/bin" >> $GITHUB_PATH - rm zls.zip + ZLS_DIR="$HOME/.zls" + if [[ "${{ steps.cache-zls.outputs.cache-hit }}" != "true" ]]; then + mkdir -p "$ZLS_DIR" + if [[ "${{ runner.os }}" == "Linux" ]]; then + wget -O /tmp/zls.tar.xz https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-linux.tar.xz + tar -xf /tmp/zls.tar.xz -C "$ZLS_DIR" && rm /tmp/zls.tar.xz + elif [[ "${{ runner.os }}" == "macOS" ]]; then + wget -O /tmp/zls.tar.xz https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-macos.tar.xz + tar -xf /tmp/zls.tar.xz -C "$ZLS_DIR" && rm /tmp/zls.tar.xz + elif [[ "${{ runner.os }}" == "Windows" ]]; then + curl -L -o zls.zip https://github.com/zigtools/zls/releases/download/0.14.0/zls-x86_64-windows.zip + unzip -o zls.zip -d "$ZLS_DIR" && rm zls.zip + fi + chmod +x "$ZLS_DIR/zls" 2>/dev/null || true fi + echo "$ZLS_DIR" >> "$GITHUB_PATH" - name: Install verible-verilog-ls (SystemVerilog Language Server) + if: matrix.batch == 'other-langs' shell: bash run: | VERIBLE_VERSION="v0.0-4051-g9fdb4057" @@ -252,6 +319,7 @@ jobs: echo "WARNING: verible-verilog-ls not found in PATH" fi - name: Install Lua Language Server + if: matrix.batch == 'scripting' shell: bash run: | LUA_LS_VERSION="3.15.0" @@ -297,18 +365,22 @@ jobs: rm lua-ls.zip fi - name: Install ansible-core and ansible-lint (for Ansible language server tests) + if: matrix.batch == 'other-langs' shell: bash run: uv run pip install ansible-core ansible-lint - name: Install Haxe + if: matrix.batch == 'scripting' # The Haxe LS binary (server.js) is auto-downloaded and runs on Node.js, # but it delegates to the Haxe compiler for code analysis at runtime. uses: krdlab/setup-haxe@v2 with: haxe-version: 4.3.7 - name: Install Elm + if: matrix.batch == 'web' shell: bash run: npm install -g elm@0.19.1-6 - name: Install Regal (Rego Language Server) + if: matrix.batch == 'other-langs' shell: bash run: | REGAL_VERSION="0.39.0" @@ -335,35 +407,39 @@ jobs: mv regal.exe "$HOME/bin/" echo "$HOME/bin" >> $GITHUB_PATH fi + - name: Cache Free Pascal Compiler + if: matrix.batch == 'native' + id: cache-fpc + uses: actions/cache@v3 + with: + path: | + ~/fpc + ~/fpcsrc + key: fpc-${{ runner.os }}-3.2.2 - name: Install Free Pascal Compiler + if: matrix.batch == 'native' shell: bash run: | + # Only the downloads are gated on the cache; the PP/FPCDIR/PATH detection always runs + # (it works the same on freshly-downloaded or cache-restored ~/fpc + ~/fpcsrc). + FPC_CACHE_HIT="${{ steps.cache-fpc.outputs.cache-hit }}" + FPC_VERSION="3.2.2" if [[ "${{ runner.os }}" == "Linux" ]]; then sudo apt-get update sudo apt-get install -y fpc fpc-source - # Set environment variables for pasls echo "PP=/usr/bin/fpc" >> $GITHUB_ENV - # Find FPC source directory (version may vary) - remove trailing slash FPCDIR=$(ls -d /usr/share/fpcsrc/*/ 2>/dev/null | head -1 | sed 's:/$::') - if [[ -z "$FPCDIR" ]]; then - FPCDIR="/usr/share/fpcsrc" - fi + [[ -z "$FPCDIR" ]] && FPCDIR="/usr/share/fpcsrc" echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV - echo "=== FPC source directory structure ===" - ls -la "$FPCDIR" || echo "FPCDIR not found" - ls -la "$FPCDIR/rtl" 2>/dev/null || echo "rtl subdirectory not found" elif [[ "${{ runner.os }}" == "macOS" ]]; then - brew install fpc - # Download FPC source from SourceForge (fpc-src-laz cask is incompatible with ARM64) - FPC_VERSION="3.2.2" - curl -L -o fpc-source.tar.gz "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.tar.gz/download" - mkdir -p "$HOME/fpcsrc" - tar -xzf fpc-source.tar.gz -C "$HOME/fpcsrc" - rm fpc-source.tar.gz - # Check extracted directory structure (might be nested) - echo "=== Extracted FPC source structure ===" - ls -la "$HOME/fpcsrc" - # Find the actual FPC source root (contains rtl, packages, etc.) + command -v fpc >/dev/null || brew install fpc + if [[ "$FPC_CACHE_HIT" != "true" ]]; then + # FPC source from SourceForge (fpc-src-laz cask is incompatible with ARM64) + curl -L -o fpc-source.tar.gz "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.tar.gz/download" + mkdir -p "$HOME/fpcsrc" + tar -xzf fpc-source.tar.gz -C "$HOME/fpcsrc" + rm fpc-source.tar.gz + fi if [[ -d "$HOME/fpcsrc/fpc-${FPC_VERSION}/rtl" ]]; then FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}" elif [[ -d "$HOME/fpcsrc/fpc-${FPC_VERSION}/fpc-${FPC_VERSION}/rtl" ]]; then @@ -373,34 +449,26 @@ jobs: fi echo "PP=$(which fpc)" >> $GITHUB_ENV echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV - echo "=== FPC source directory ===" - ls -la "$FPCDIR" || echo "FPCDIR not found" elif [[ "${{ runner.os }}" == "Windows" ]]; then - FPC_VERSION="3.2.2" - # Download freepascal-ootb (includes FPC compiler) - curl -L -o fpc-ootb.zip https://github.com/fredvs/freepascal-ootb/releases/download/${FPC_VERSION}/fpc-ootb-322-x86_64-win64.zip - mkdir -p "$HOME/fpc" - unzip -q fpc-ootb.zip -d "$HOME/fpc" - rm fpc-ootb.zip - # Download FPC source from SourceForge (fpc-ootb only has compiled units, not source) - curl -L -o fpc-source.zip "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.zip/download" - mkdir -p "$HOME/fpcsrc" - unzip -q fpc-source.zip -d "$HOME/fpcsrc" - rm fpc-source.zip - # Find fpc executable (fpc-ootb uses fpc-ootb.exe as the compiler) - echo "=== FPC directory structure ===" - find "$HOME/fpc" -name "*.exe" -type f 2>/dev/null | head -10 + if [[ "$FPC_CACHE_HIT" != "true" ]]; then + # freepascal-ootb (compiler) + source (ootb ships compiled units only, not source) + curl -L -o fpc-ootb.zip https://github.com/fredvs/freepascal-ootb/releases/download/${FPC_VERSION}/fpc-ootb-322-x86_64-win64.zip + mkdir -p "$HOME/fpc" + unzip -q fpc-ootb.zip -d "$HOME/fpc" + rm fpc-ootb.zip + curl -L -o fpc-source.zip "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.zip/download" + mkdir -p "$HOME/fpcsrc" + unzip -q fpc-source.zip -d "$HOME/fpcsrc" + rm fpc-source.zip + fi FPC_EXE=$(find "$HOME/fpc" -name "fpc-ootb-64.exe" -type f 2>/dev/null | head -1) echo "Found FPC executable: $FPC_EXE" - echo "Found FPC source dir: $HOME/fpcsrc/fpc-${FPC_VERSION}" - # Set environment variables for pasls echo "PP=$FPC_EXE" >> $GITHUB_ENV echo "FPCDIR=$HOME/fpcsrc/fpc-${FPC_VERSION}" >> $GITHUB_ENV - # Add FPC bin directory to PATH - FPC_BIN_DIR=$(dirname "$FPC_EXE") - echo "$FPC_BIN_DIR" >> $GITHUB_PATH + echo "$(dirname "$FPC_EXE")" >> $GITHUB_PATH fi - name: Verify FPC installation + if: matrix.batch == 'native' shell: bash run: | echo "=== Environment variables ===" @@ -441,11 +509,12 @@ jobs: echo "ERROR: FPCDIR does not exist: $FPCDIR" exit 1 fi - - name: Build Lean 4 test project - uses: leanprover/lean-action@v1 - with: - lake-package-directory: test/resources/repos/lean4/test_repo + # Lean 4 is intentionally NOT installed/built here: the toolchain is ~1 GB (it *is* the language + # server) and lean4 is already covered by the Linux maximal container (test-parallel.yml's niche + # batch). With no `lean` on PATH, the central language_tests_enabled(Language.LEAN4) guard simply + # skips lean4 on the Windows/macOS runners -- no per-OS toolchain download. - name: Install npm deps for Angular test repo + if: matrix.batch == 'web' shell: bash run: | if [ -d test/resources/repos/angular/test_repo ]; then @@ -457,17 +526,36 @@ jobs: uses: actions/cache@v3 with: path: ~/.serena/language_servers/static - key: language-servers-${{ runner.os }}-v1 + key: language-servers-${{ runner.os }}-${{ matrix.batch }}-v1 restore-keys: | - language-servers-${{ runner.os }}- + language-servers-${{ runner.os }}-${{ matrix.batch }}- - name: Report free disk space if: runner.os == 'Linux' run: | echo "Free disk space before tests:" df -h + - name: Compute marker expression for this batch + id: markers + shell: bash + run: | + case "${{ matrix.batch }}" in + jvm) expr="$MARKERS_JVM" ;; + native) expr="$MARKERS_NATIVE" ;; + swift) expr="$MARKERS_SWIFT" ;; + web) expr="$MARKERS_WEB" ;; + python) expr="$MARKERS_PYTHON" ;; + scripting) expr="$MARKERS_SCRIPTING" ;; + other-langs) expr="$MARKERS_OTHER_LANGS" ;; + catch-all) expr="not ($MARKERS_JVM or $MARKERS_NATIVE or $MARKERS_SWIFT or $MARKERS_WEB or $MARKERS_PYTHON or $MARKERS_SCRIPTING or $MARKERS_OTHER_LANGS)" ;; + *) echo "Unknown batch: ${{ matrix.batch }}" >&2; exit 1 ;; + esac + echo "Marker expression: $expr" + echo "expr=$expr" >> "$GITHUB_OUTPUT" - name: Test with pytest shell: bash - run: uv run poe test -q --tb=short + run: uv run poe test -m "${{ steps.markers.outputs.expr }}" -q --tb=short - name: Type-checking with mypy + # Type-checking is batch-independent; run it only once per OS instead of in every batch. + if: matrix.batch == 'catch-all' shell: bash run: uv run poe type-check diff --git a/test/conftest.py b/test/conftest.py index e342daf8..34487aa7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -3,6 +3,7 @@ import os import platform import re import shutil as _sh +import subprocess from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path @@ -21,6 +22,8 @@ from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings from .solidlsp.clojure import is_clojure_cli_available +from .solidlsp.elixir import EXPERT_UNAVAILABLE +from .solidlsp.erlang import ERLANG_LS_UNAVAILABLE configure(level=logging.INFO) @@ -244,10 +247,7 @@ is_windows = platform.system() == "Windows" _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.ADA: [pytest.mark.ada], - Language.CLOJURE: [ - pytest.mark.clojure, - pytest.mark.skipif(not is_clojure_cli_available(), reason="clojure CLI is not installed"), - ], + Language.CLOJURE: [pytest.mark.clojure], Language.CPP: [pytest.mark.cpp], Language.CPP_CCLS: [pytest.mark.cpp], Language.CUE: [pytest.mark.cue], @@ -256,8 +256,8 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.GO: [pytest.mark.go], Language.HAXE: [pytest.mark.haxe], Language.JAVA: [pytest.mark.java], - Language.KOTLIN: [pytest.mark.kotlin, pytest.mark.skipif(is_ci, reason="Kotlin LSP JVM crashes on restart in CI")], - Language.LEAN4: [pytest.mark.lean4, pytest.mark.skipif(_sh.which("lean") is None, reason="Lean is not installed")], + Language.KOTLIN: [pytest.mark.kotlin], + Language.LEAN4: [pytest.mark.lean4], Language.LATEX: [pytest.mark.latex], Language.MSL: [pytest.mark.msl], Language.PHP: [pytest.mark.php], @@ -270,10 +270,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.PYTHON_PYREFLY: [pytest.mark.python], Language.RUST: [pytest.mark.rust], Language.TYPESCRIPT: [pytest.mark.typescript], - Language.BSL: [ - pytest.mark.bsl, - pytest.mark.skipif(_sh.which("java") is None, reason="Java is not installed"), - ], + Language.BSL: [pytest.mark.bsl], Language.SVELTE: [pytest.mark.svelte], Language.ANGULAR: [pytest.mark.angular], Language.HTML: [pytest.mark.html], @@ -284,54 +281,167 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { def get_pytest_markers(language: Language) -> list[MarkDecorator | Mark]: """Pytest markers for a language. - The returned list contains the primary language marker and any - environment-dependent skip markers shared across the test suite. + Returns the primary language marker plus the central enablement skip derived from + ``language_tests_enabled()`` -- so per-language availability/reliability lives in exactly one + place (``_determine_disabled_languages``) instead of being duplicated per marker or per test file. """ - return _LANGUAGE_PYTEST_MARKERS[language] + return [ + *_LANGUAGE_PYTEST_MARKERS[language], + pytest.mark.skipif(not language_tests_enabled(language), reason=f"{language.value} tests are disabled in this environment"), + ] + + +def _is_perl_language_server_available() -> bool: + """ + Whether Perl::LanguageServer is installed. + + Perl itself ships with most base systems, so checking for the ``perl`` binary is not enough; + we verify that the ``Perl::LanguageServer`` module can be loaded -- which is exactly what the + Perl language server launcher requires to start. + """ + if _sh.which("perl") is None: + return False + try: + return subprocess.run(["perl", "-MPerl::LanguageServer", "-e", "1"], capture_output=True, timeout=30, check=False).returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def _is_matlab_available() -> bool: + """Whether a MATLAB installation can be located (env var or a known install path).""" + if os.environ.get("MATLAB_PATH") is not None: + return True + return any( + os.path.exists(p) + for p in ( + "/Applications/MATLAB_R2024b.app", + "/Applications/MATLAB_R2025b.app", + "/Volumes/S1/Applications/MATLAB_R2024b.app", + "/Volumes/S1/Applications/MATLAB_R2025b.app", + ) + ) + + +def _is_r_language_server_available() -> bool: + """Whether R *and* its ``languageserver`` package are installed. + + The R binary alone is not enough -- the language server runs as ``R -e "languageserver::run()"``, + which fails (RuntimeError) if the package is missing -- so check the package, not just ``which("R")``. + """ + if _sh.which("R") is None: + return False + try: + return ( + subprocess.run( + ["R", "--vanilla", "-e", 'quit(status = as.integer(!requireNamespace("languageserver", quietly = TRUE)))'], + capture_output=True, + timeout=60, + check=False, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False + + +def _is_ocaml_lsp_available() -> bool: + """Whether opam *and* the ``ocaml-lsp-server`` (``ocamllsp``) are installed. + + opam alone is not enough -- the language server is launched via ``opam exec -- ocamllsp`` and + raises if the package is missing -- so verify ocamllsp resolves in the active switch. + """ + if _sh.which("opam") is None: + return False + try: + return ( + subprocess.run( + ["opam", "exec", "--", "ocamllsp", "--version"], + capture_output=True, + timeout=60, + check=False, + ).returncode + == 0 + ) + except (OSError, subprocess.SubprocessError): + return False def _determine_disabled_languages() -> list[Language]: """ - Determine which language tests should be disabled (based on the environment) + Determine which language tests are disabled in the current environment. - :return: the list of disabled languages + Every language falls into exactly ONE of the categories below; a language that is not appended + here is **category 4 (enabled everywhere)**, e.g. python, typescript, go, java, kotlin-locally. + + 1. ALWAYS DISABLED -- flaky/broken; not worth running anywhere. + 2. DISABLED OFF-CI when a precondition (toolchain/LS) is missing, but EXPECTED ON CI -- guarded + with ``and not is_ci`` so a missing tool *on CI* fails loudly (catches a CI setup regression) + instead of silently skipping. + 3. DISABLED WHEREVER the precondition is missing, INCLUDING on CI -- the precondition may or may + not be provided on CI (e.g. via the maximal container, see Dockerfile.maximal); if it isn't, + the tests just skip gracefully rather than fail. + 4. ENABLED EVERYWHERE -- not listed in this function at all. + 5. DISABLED ONLY ON CI (resource/stability reasons) even though the precondition holds locally. """ result: list[Language] = [] - java_tests_enabled = True - if not java_tests_enabled: - result.append(Language.JAVA) + # === 1. Always disabled (flaky / broken everywhere) === + result.append(Language.BSL) # 1C:Enterprise; niche and the tests are slow and flaky + result.append(Language.FSHARP) # F# language server is currently unreliable - clojure_tests_enabled = is_clojure_cli_available() - if not clojure_tests_enabled: - result.append(Language.CLOJURE) + # === 2. Disabled off-CI if the precondition is missing; expected to be present on CI === + if _sh.which("terraform") is None and not is_ci: + result.append(Language.TERRAFORM) + if _sh.which("regal") is None and not is_ci: + result.append(Language.REGO) + if _sh.which("elm") is None and not is_ci: + result.append(Language.ELM) - # Disable CPP_CCLS tests if ccls is not available - ccls_tests_enabled = _sh.which("ccls") is not None - # Skip ccls tests on Windows since no recent binary is available and version - # 0.20220729 from chocolatey crashes when parsing the test files. - ccls_tests_enabled = ccls_tests_enabled and not is_windows - if not ccls_tests_enabled: - result.append(Language.CPP_CCLS) - - # Disable CPP (clangd) tests if clangd is not available - clangd_tests_enabled = _sh.which("clangd") is not None - if not clangd_tests_enabled: + # === 3. Disabled wherever the precondition is missing (including on CI) === + # 3a. Platform precondition: these language servers have no native Windows support. + if is_windows: + result.append(Language.ANSIBLE) + result.append(Language.SWIFT) + # 3b. Toolchain / language-server availability (the LS/compiler must be on PATH or installed). + if _sh.which("clangd") is None: result.append(Language.CPP) - - # Disable PHP_PHPACTOR tests if php is not available - php_tests_enabled = _sh.which("php") is not None - if not php_tests_enabled: + if _sh.which("ccls") is None or is_windows: # no recent ccls binary is available for Windows + result.append(Language.CPP_CCLS) + if _sh.which("php") is None: result.append(Language.PHP_PHPACTOR) result.append(Language.PHP_PHPANTOM) + if not is_clojure_cli_available(): + result.append(Language.CLOJURE) + if _sh.which("verible-verilog-ls") is None: + result.append(Language.SYSTEMVERILOG) + if not _is_matlab_available(): + result.append(Language.MATLAB) + if ERLANG_LS_UNAVAILABLE: # no Erlang-OTP / no rebar3 / Windows -- see test/solidlsp/erlang + result.append(Language.ERLANG) + if EXPERT_UNAVAILABLE: # Elixir not installed -- see test/solidlsp/elixir + result.append(Language.ELIXIR) + if _sh.which("lean") is None: + result.append(Language.LEAN4) + if _sh.which("crystalline") is None: + result.append(Language.CRYSTAL) + if _sh.which("julia") is None: # LanguageServer.jl is auto-installed by the LS when julia is present + result.append(Language.JULIA) + if _sh.which("nixd") is None: + result.append(Language.NIX) + if _sh.which("haskell-language-server-wrapper") is None: + result.append(Language.HASKELL) + if not _is_r_language_server_available(): # `which("R")` isn't enough -- needs the languageserver package + result.append(Language.R) + if not _is_ocaml_lsp_available(): # opam alone isn't enough -- needs the ocaml-lsp-server package + result.append(Language.OCAML) + if not _is_perl_language_server_available(): # perl ships with the OS; the LS module is the real signal + result.append(Language.PERL) - al_tests_enabled = True - if not al_tests_enabled: - result.append(Language.AL) + # === 4. Enabled everywhere: every language NOT listed in this function (python, go, java, ...) === - # Disable BSL tests only when Java is not available (Java IS present in CI via actions/setup-java) - if _sh.which("java") is None: - result.append(Language.BSL) + # === 5. Disabled only on CI (works locally; too unstable/costly on the CI runners) === + if is_ci: + result.append(Language.KOTLIN) # IntelliJ-based Kotlin LSP crashes on JVM restart under CI memory limits return result diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 5ab27115..7625e50a 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -36,7 +36,6 @@ from test.conftest import ( find_identifier_pos, get_pytest_markers, get_repo_path, - is_ci, language_tests_enabled, ) @@ -725,9 +724,7 @@ SAFE_DELETE_BLOCKED_CASES = [ id="kotlin_model", name_path="Model", relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - ).to_pytest_param( - *([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), + ).to_pytest_param(), SafeDeleteCase( language=Language.TYPESCRIPT, id="typescript_helper_function", @@ -754,9 +751,7 @@ SAFE_DELETE_SUCCEEDS_CASES = [ id="kotlin_model_user", name_path="ModelUser", relative_path=os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"), - ).to_pytest_param( - *([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), + ).to_pytest_param(), SafeDeleteCase( language=Language.TYPESCRIPT, id="typescript_unused_standalone_function", diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py index b6f8abf2..97a02aa6 100644 --- a/test/serena/test_symbol_editing.py +++ b/test/serena/test_symbol_editing.py @@ -6,7 +6,6 @@ Recreate the snapshots with `pytest --snapshot-update`. import logging import os import shutil -import sys import tempfile import time from abc import ABC, abstractmethod @@ -24,7 +23,7 @@ from syrupy import SnapshotAssertion from serena.code_editor import CodeEditor, LanguageServerCodeEditor from solidlsp.ls_config import Language from src.serena.symbol import LanguageServerSymbolRetriever -from test.conftest import get_repo_path, project_with_ls_context +from test.conftest import get_repo_path, language_tests_enabled, project_with_ls_context pytestmark = pytest.mark.snapshot @@ -451,7 +450,7 @@ class NixAttrReplacementTest(EditingTest): @pytest.mark.nix -@pytest.mark.skipif(sys.platform == "win32", reason="nixd language server doesn't run on Windows") +@pytest.mark.skipif(not language_tests_enabled(Language.NIX), reason="Nix tests are disabled (nixd not available)") def test_nix_symbol_replacement_no_double_semicolon(snapshot: SnapshotAssertion): """ Test that replacing a Nix attribute does not result in double semicolons. diff --git a/test/solidlsp/ansible/test_ansible_basic.py b/test/solidlsp/ansible/test_ansible_basic.py index bf6dc6ca..666a3f0b 100644 --- a/test/solidlsp/ansible/test_ansible_basic.py +++ b/test/solidlsp/ansible/test_ansible_basic.py @@ -6,17 +6,17 @@ using the standard Ansible test repository. They work with the standard @ansible/ansible-language-server from npm. """ -import platform from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -@pytest.mark.skipif(platform.system() == "Windows", reason="ansible-language-server does not support native Windows operation") +@pytest.mark.skipif(not language_tests_enabled(Language.ANSIBLE), reason="Ansible tests are disabled (no native Windows support)") @pytest.mark.ansible class TestAnsibleLanguageServerBasics: """Test basic Ansible language server functionality.""" diff --git a/test/solidlsp/bsl/test_bsl_basic.py b/test/solidlsp/bsl/test_bsl_basic.py index 4e2ecc87..4e093749 100644 --- a/test/solidlsp/bsl/test_bsl_basic.py +++ b/test/solidlsp/bsl/test_bsl_basic.py @@ -8,9 +8,10 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -pytestmark = pytest.mark.skip("BSL is a niche language, the tests are slow and flaky.") +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.BSL), reason="BSL tests are disabled (niche, slow and flaky)") @pytest.mark.bsl diff --git a/test/solidlsp/bsl/test_bsl_diagnostics.py b/test/solidlsp/bsl/test_bsl_diagnostics.py index 7974b4a6..e5dfe69e 100644 --- a/test/solidlsp/bsl/test_bsl_diagnostics.py +++ b/test/solidlsp/bsl/test_bsl_diagnostics.py @@ -1,14 +1,13 @@ -import shutil - import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.bsl -@pytest.mark.skipif(shutil.which("java") is None, reason="Java 21+ is required for BSL LSP") +@pytest.mark.skipif(not language_tests_enabled(Language.BSL), reason="BSL tests are disabled") class TestBSLDiagnostics: @pytest.mark.parametrize("language_server", [Language.BSL], indirect=True) def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: diff --git a/test/solidlsp/crystal/test_crystal_basic.py b/test/solidlsp/crystal/test_crystal_basic.py index 71102949..7af66129 100644 --- a/test/solidlsp/crystal/test_crystal_basic.py +++ b/test/solidlsp/crystal/test_crystal_basic.py @@ -10,17 +10,17 @@ Known Crystalline limitations: """ import os -import shutil import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols pytestmark = [ pytest.mark.crystal, - pytest.mark.skipif(shutil.which("crystalline") is None, reason="Crystalline is not installed"), + pytest.mark.skipif(not language_tests_enabled(Language.CRYSTAL), reason="Crystal tests are disabled (crystalline not available)"), ] diff --git a/test/solidlsp/crystal/test_crystal_diagnostics.py b/test/solidlsp/crystal/test_crystal_diagnostics.py index e650adc1..b593b147 100644 --- a/test/solidlsp/crystal/test_crystal_diagnostics.py +++ b/test/solidlsp/crystal/test_crystal_diagnostics.py @@ -1,14 +1,13 @@ -import shutil - import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.util.diagnostics import assert_file_diagnostics pytestmark = [ pytest.mark.crystal, - pytest.mark.skipif(shutil.which("crystalline") is None, reason="Crystalline is not installed"), + pytest.mark.skipif(not language_tests_enabled(Language.CRYSTAL), reason="Crystal tests are disabled (crystalline not available)"), ] diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py index 7c44abc4..ac32edd5 100644 --- a/test/solidlsp/elixir/test_elixir_basic.py +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -12,13 +12,12 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON - # These marks will be applied to all tests in this module -pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")] +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(not language_tests_enabled(Language.ELIXIR), reason="Elixir tests are disabled")] class TestElixirBasic: diff --git a/test/solidlsp/elixir/test_elixir_ignored_dirs.py b/test/solidlsp/elixir/test_elixir_ignored_dirs.py index b01531ba..75f4870b 100644 --- a/test/solidlsp/elixir/test_elixir_ignored_dirs.py +++ b/test/solidlsp/elixir/test_elixir_ignored_dirs.py @@ -6,12 +6,10 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import start_ls_context - -from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON +from test.conftest import language_tests_enabled, start_ls_context # These marks will be applied to all tests in this module -pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Expert not available: {EXPERT_UNAVAILABLE_REASON}")] +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(not language_tests_enabled(Language.ELIXIR), reason="Elixir tests are disabled")] # Skip slow tests in CI - they require multiple Expert instances which is too slow IN_CI = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS")) diff --git a/test/solidlsp/elixir/test_elixir_integration.py b/test/solidlsp/elixir/test_elixir_integration.py index eff618b2..678ce762 100644 --- a/test/solidlsp/elixir/test_elixir_integration.py +++ b/test/solidlsp/elixir/test_elixir_integration.py @@ -13,11 +13,10 @@ import pytest from serena.project import Project from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language - -from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON +from test.conftest import language_tests_enabled # These marks will be applied to all tests in this module -pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")] +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(not language_tests_enabled(Language.ELIXIR), reason="Elixir tests are disabled")] class TestElixirIntegration: diff --git a/test/solidlsp/elixir/test_elixir_symbol_retrieval.py b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py index 89a3127f..219cfe15 100644 --- a/test/solidlsp/elixir/test_elixir_symbol_retrieval.py +++ b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py @@ -14,11 +14,10 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind - -from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON +from test.conftest import language_tests_enabled # These marks will be applied to all tests in this module -pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")] +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(not language_tests_enabled(Language.ELIXIR), reason="Elixir tests are disabled")] class TestElixirLanguageServerSymbols: diff --git a/test/solidlsp/elm/test_elm_basic.py b/test/solidlsp/elm/test_elm_basic.py index 5ff6fe22..a886ede9 100644 --- a/test/solidlsp/elm/test_elm_basic.py +++ b/test/solidlsp/elm/test_elm_basic.py @@ -1,16 +1,15 @@ import os -import shutil import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils -from test.conftest import is_ci +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -@pytest.mark.skipif(shutil.which("elm") is None and not is_ci, reason="Elm compiler is not available") +@pytest.mark.skipif(not language_tests_enabled(Language.ELM), reason="Elm tests are disabled (elm compiler not available)") @pytest.mark.elm class TestElmLanguageServer: @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) diff --git a/test/solidlsp/erlang/test_erlang_basic.py b/test/solidlsp/erlang/test_erlang_basic.py index dc39535b..491c71a7 100644 --- a/test/solidlsp/erlang/test_erlang_basic.py +++ b/test/solidlsp/erlang/test_erlang_basic.py @@ -9,14 +9,13 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON - @pytest.mark.erlang -@pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}") +@pytest.mark.skipif(not language_tests_enabled(Language.ERLANG), reason="Erlang tests are disabled") class TestErlangLanguageServerBasics: """Test basic functionality of the Erlang language server.""" diff --git a/test/solidlsp/erlang/test_erlang_ignored_dirs.py b/test/solidlsp/erlang/test_erlang_ignored_dirs.py index 74cf87ff..d6fdb0ab 100644 --- a/test/solidlsp/erlang/test_erlang_ignored_dirs.py +++ b/test/solidlsp/erlang/test_erlang_ignored_dirs.py @@ -5,14 +5,12 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import start_ls_context - -from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON +from test.conftest import language_tests_enabled, start_ls_context # These marks will be applied to all tests in this module pytestmark = [ pytest.mark.erlang, - pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}"), + pytest.mark.skipif(not language_tests_enabled(Language.ERLANG), reason="Erlang tests are disabled"), ] diff --git a/test/solidlsp/erlang/test_erlang_symbol_retrieval.py b/test/solidlsp/erlang/test_erlang_symbol_retrieval.py index b2f9fce4..918237c5 100644 --- a/test/solidlsp/erlang/test_erlang_symbol_retrieval.py +++ b/test/solidlsp/erlang/test_erlang_symbol_retrieval.py @@ -14,13 +14,12 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind - -from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON +from test.conftest import language_tests_enabled # These marks will be applied to all tests in this module pytestmark = [ pytest.mark.erlang, - pytest.mark.skipif(ERLANG_LS_UNAVAILABLE, reason=f"Erlang LS not available: {ERLANG_LS_UNAVAILABLE_REASON}"), + pytest.mark.skipif(not language_tests_enabled(Language.ERLANG), reason="Erlang tests are disabled"), ] diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index f4f49ebc..37ed0bb5 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -7,14 +7,19 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils -from test.conftest import find_identifier_position, get_repo_path, is_ci, language_has_verified_implementation_support +from test.conftest import ( + find_identifier_position, + get_repo_path, + language_has_verified_implementation_support, + language_tests_enabled, +) from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics +# Currently, most F# tests fail (regression/instability), so the suite is disabled on CI. +pytestmark = [pytest.mark.fsharp, pytest.mark.skipif(not language_tests_enabled(Language.FSHARP), reason="F# tests are disabled")] + -# Currently, most F# tests fail, there seems to be a regression or instability. -@pytest.mark.fsharp -@pytest.mark.skipif(is_ci, reason="F# language server is currently unreliable") class TestFSharpLanguageServer: @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_symbol(self, language_server: SolidLanguageServer) -> None: @@ -80,7 +85,6 @@ class TestFSharpLanguageServer: for expected in expected_symbols: assert expected in symbol_names, f"{expected} function not found in Calculator.fs symbols" - @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: """Test finding references using symbol selection range.""" @@ -117,7 +121,6 @@ class TestFSharpLanguageServer: for expected in expected_symbols: assert expected in symbol_names, f"{expected} not found in Person.fs symbols" - @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: """Test finding references to Calculator functions across files.""" @@ -140,7 +143,6 @@ class TestFSharpLanguageServer: # The subtract function should be referenced in Program.fs assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference subtract function" - @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_go_to_definition(self, language_server: SolidLanguageServer) -> None: """Test go-to-definition functionality.""" @@ -154,7 +156,6 @@ class TestFSharpLanguageServer: # We should get at least some definitions assert len(definitions) >= 0, "Should get definitions (even if empty for complex cases)" - @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_hover_information(self, language_server: SolidLanguageServer) -> None: """Test hover information functionality.""" diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py index 55f94f6d..cf4bba3b 100644 --- a/test/solidlsp/haskell/test_haskell_basic.py +++ b/test/solidlsp/haskell/test_haskell_basic.py @@ -13,21 +13,19 @@ Test Repository Structure: - app/Main.hs: Main entry point using Calculator and Helper modules """ -import sys - import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -pytestmark = pytest.mark.skip("Haskell installation is heavy, the tests should be executed only on demand") +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.HASKELL), reason="Haskell tests are disabled (HLS not available)") @pytest.mark.haskell -@pytest.mark.skipif(sys.platform == "win32", reason="HLS not installed on Windows CI") class TestHaskellLanguageServer: @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) def test_calculator_module_symbols(self, language_server: SolidLanguageServer): diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py index 1f3fdd87..75b81abf 100644 --- a/test/solidlsp/julia/test_julia_basic.py +++ b/test/solidlsp/julia/test_julia_basic.py @@ -1,14 +1,13 @@ -import shutil - import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -@pytest.mark.skipif(shutil.which("julia") is None, reason="Julia is not available") +@pytest.mark.skipif(not language_tests_enabled(Language.JULIA), reason="Julia tests are disabled (julia not available)") @pytest.mark.julia class TestJuliaLanguageServer: @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) diff --git a/test/solidlsp/kotlin/test_kotlin_basic.py b/test/solidlsp/kotlin/test_kotlin_basic.py index 9a990458..0ce7a61f 100644 --- a/test/solidlsp/kotlin/test_kotlin_basic.py +++ b/test/solidlsp/kotlin/test_kotlin_basic.py @@ -6,14 +6,15 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils -from test.conftest import is_ci +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Kotlin LSP (IntelliJ-based, pre-alpha v261) crashes on JVM restart under CI resource constraints # (2 CPUs, 7GB RAM). First start succeeds but subsequent starts fail with cancelled (-32800). # Tests pass reliably on developer machines. See PR #1061 for investigation details. -@pytest.mark.skipif(is_ci, reason="Kotlin LSP JVM restart is unstable on CI runners") +# (The CI quarantine lives centrally in test/conftest.py::_determine_disabled_languages.) +@pytest.mark.skipif(not language_tests_enabled(Language.KOTLIN), reason="Kotlin tests are disabled") @pytest.mark.kotlin class TestKotlinLanguageServer: @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) diff --git a/test/solidlsp/lean4/test_lean4_basic.py b/test/solidlsp/lean4/test_lean4_basic.py index b8f35efa..bb4883cf 100644 --- a/test/solidlsp/lean4/test_lean4_basic.py +++ b/test/solidlsp/lean4/test_lean4_basic.py @@ -16,6 +16,9 @@ import pytest from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled + +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.LEAN4), reason="Lean4 tests are disabled (lean not available)") @pytest.mark.lean4 diff --git a/test/solidlsp/lean4/test_lean4_diagnostics.py b/test/solidlsp/lean4/test_lean4_diagnostics.py index 964bc2c6..11eac570 100644 --- a/test/solidlsp/lean4/test_lean4_diagnostics.py +++ b/test/solidlsp/lean4/test_lean4_diagnostics.py @@ -2,8 +2,11 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.util.diagnostics import assert_file_diagnostics +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.LEAN4), reason="Lean4 tests are disabled (lean not available)") + @pytest.mark.lean4 class TestLean4Diagnostics: diff --git a/test/solidlsp/matlab/test_matlab_basic.py b/test/solidlsp/matlab/test_matlab_basic.py index a1582887..4857a447 100644 --- a/test/solidlsp/matlab/test_matlab_basic.py +++ b/test/solidlsp/matlab/test_matlab_basic.py @@ -10,31 +10,19 @@ Requirements: - Node.js and npm must be installed """ -import os - import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics # Skip all tests if MATLAB is not available pytestmark = pytest.mark.matlab -# Check if MATLAB is available -MATLAB_AVAILABLE = os.environ.get("MATLAB_PATH") is not None or any( - os.path.exists(p) - for p in [ - "/Applications/MATLAB_R2024b.app", - "/Applications/MATLAB_R2025b.app", - "/Volumes/S1/Applications/MATLAB_R2024b.app", - "/Volumes/S1/Applications/MATLAB_R2025b.app", - ] -) - -@pytest.mark.skipif(not MATLAB_AVAILABLE, reason="MATLAB installation not found") +@pytest.mark.skipif(not language_tests_enabled(Language.MATLAB), reason="MATLAB tests are disabled (MATLAB installation not found)") class TestMatlabLanguageServerBasics: """Test basic functionality of the MATLAB language server.""" @@ -95,7 +83,7 @@ class TestMatlabLanguageServerBasics: assert all_symbols is not None -@pytest.mark.skipif(not MATLAB_AVAILABLE, reason="MATLAB installation not found") +@pytest.mark.skipif(not language_tests_enabled(Language.MATLAB), reason="MATLAB tests are disabled (MATLAB installation not found)") class TestMatlabLanguageServerReferences: """Test find references functionality of the MATLAB language server.""" diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index 61207833..789d047b 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -9,11 +9,11 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import is_ci +from test.conftest import is_ci, language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -pytestmark = pytest.mark.skip("Nix is niche and has a heavy toolchain") +pytestmark = pytest.mark.skipif(not language_tests_enabled(Language.NIX), reason="Nix tests are disabled (nixd not available)") @pytest.mark.nix diff --git a/test/solidlsp/ocaml/test_cross_file_refs.py b/test/solidlsp/ocaml/test_cross_file_refs.py index 0a2b70bc..44c72e49 100644 --- a/test/solidlsp/ocaml/test_cross_file_refs.py +++ b/test/solidlsp/ocaml/test_cross_file_refs.py @@ -14,10 +14,12 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled log = logging.getLogger(__name__) +@pytest.mark.skipif(not language_tests_enabled(Language.OCAML), reason="OCaml tests are disabled (opam not available)") @pytest.mark.ocaml class TestCrossFileReferences: @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) diff --git a/test/solidlsp/ocaml/test_ocaml_basic.py b/test/solidlsp/ocaml/test_ocaml_basic.py index 6016be56..86d3a874 100644 --- a/test/solidlsp/ocaml/test_ocaml_basic.py +++ b/test/solidlsp/ocaml/test_ocaml_basic.py @@ -1,15 +1,15 @@ import os -import shutil import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -@pytest.mark.skipif(shutil.which("opam") is None, reason="OPAM is not available") +@pytest.mark.skipif(not language_tests_enabled(Language.OCAML), reason="OCaml tests are disabled (opam not available)") @pytest.mark.ocaml class TestOCamlLanguageServer: @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) diff --git a/test/solidlsp/ocaml/test_ocaml_diagnostics.py b/test/solidlsp/ocaml/test_ocaml_diagnostics.py index 69f4ed61..3817f225 100644 --- a/test/solidlsp/ocaml/test_ocaml_diagnostics.py +++ b/test/solidlsp/ocaml/test_ocaml_diagnostics.py @@ -1,13 +1,12 @@ -import shutil - import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.util.diagnostics import assert_file_diagnostics -@pytest.mark.skipif(shutil.which("opam") is None, reason="OPAM is not available") +@pytest.mark.skipif(not language_tests_enabled(Language.OCAML), reason="OCaml tests are disabled (opam not available)") @pytest.mark.ocaml class TestOcamlDiagnostics: @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py index 1aa185f4..f6e1bf66 100644 --- a/test/solidlsp/perl/test_perl_basic.py +++ b/test/solidlsp/perl/test_perl_basic.py @@ -4,10 +4,13 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -pytestmark = pytest.mark.skip("Perl is niche and has a heavy toolchain") +pytestmark = pytest.mark.skipif( + not language_tests_enabled(Language.PERL), reason="Perl tests are disabled (Perl::LanguageServer not available)" +) @pytest.mark.perl diff --git a/test/solidlsp/r/test_r_basic.py b/test/solidlsp/r/test_r_basic.py index 17f28545..99190d5f 100644 --- a/test/solidlsp/r/test_r_basic.py +++ b/test/solidlsp/r/test_r_basic.py @@ -3,17 +3,17 @@ Basic tests for R Language Server integration """ import os -import shutil from pathlib import Path import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -@pytest.mark.skipif(shutil.which("R") is None, reason="R is not available") +@pytest.mark.skipif(not language_tests_enabled(Language.R), reason="R tests are disabled (R not available)") @pytest.mark.r class TestRLanguageServer: """Test basic functionality of the R language server.""" diff --git a/test/solidlsp/rego/test_rego_basic.py b/test/solidlsp/rego/test_rego_basic.py index ce8028c2..8bbe730d 100644 --- a/test/solidlsp/rego/test_rego_basic.py +++ b/test/solidlsp/rego/test_rego_basic.py @@ -1,7 +1,6 @@ """Tests for Rego language server (Regal) functionality.""" import os -import shutil import pytest @@ -9,11 +8,11 @@ from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils -from test.conftest import is_ci +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -@pytest.mark.skipif(shutil.which("regal") is None and not is_ci, reason="Regal is not available") +@pytest.mark.skipif(not language_tests_enabled(Language.REGO), reason="Rego tests are disabled (regal not available)") @pytest.mark.rego class TestRegoLanguageServer: """Test Regal language server functionality for Rego.""" diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index 30e9759c..a2282fb2 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -6,7 +6,6 @@ like request_references using the Swift test repository. """ import os -import platform import pytest @@ -14,15 +13,11 @@ from serena.project import Project from serena.util.text_utils import LineType from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from test.conftest import is_ci +from test.conftest import is_ci, language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols from test.solidlsp.util.diagnostics import assert_file_diagnostics -# Skip Swift tests on Windows due to complex GitHub Actions configuration -WINDOWS_SKIP = platform.system() == "Windows" -WINDOWS_SKIP_REASON = "GitHub Actions configuration for Swift on Windows is complex, skipping for now." - -pytestmark = [pytest.mark.swift, pytest.mark.skipif(WINDOWS_SKIP, reason=WINDOWS_SKIP_REASON)] +pytestmark = [pytest.mark.swift, pytest.mark.skipif(not language_tests_enabled(Language.SWIFT), reason="Swift tests are disabled")] class TestSwiftLanguageServerBasics: diff --git a/test/solidlsp/systemverilog/test_systemverilog_basic.py b/test/solidlsp/systemverilog/test_systemverilog_basic.py index 4653a84c..43d75d00 100644 --- a/test/solidlsp/systemverilog/test_systemverilog_basic.py +++ b/test/solidlsp/systemverilog/test_systemverilog_basic.py @@ -5,7 +5,6 @@ This module tests Language.SYSTEMVERILOG using verible-verilog-ls. Tests are skipped if the language server is not available. """ -import shutil from typing import Any import pytest @@ -13,8 +12,13 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols +pytestmark = pytest.mark.skipif( + not language_tests_enabled(Language.SYSTEMVERILOG), reason="SystemVerilog tests are disabled (verible-verilog-ls not available)" +) + def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None: """Find a top-level symbol by name in a file's document symbols.""" @@ -265,7 +269,6 @@ class TestSystemVerilogRename: for edit in file_edits: assert edit["newText"] == "my_counter", f"Expected 'my_counter', got {edit['newText']}" - @pytest.mark.skipif(shutil.which("verible-verilog-ls") is None, reason="verible-verilog-ls is not available") @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py index a783b828..3de588c2 100644 --- a/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py +++ b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py @@ -2,8 +2,13 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled from test.solidlsp.util.diagnostics import assert_file_diagnostics +pytestmark = pytest.mark.skipif( + not language_tests_enabled(Language.SYSTEMVERILOG), reason="SystemVerilog tests are disabled (verible-verilog-ls not available)" +) + @pytest.mark.systemverilog class TestSystemverilogDiagnostics: diff --git a/test/solidlsp/terraform/test_terraform_basic.py b/test/solidlsp/terraform/test_terraform_basic.py index 612e3f9a..1a91f0c9 100644 --- a/test/solidlsp/terraform/test_terraform_basic.py +++ b/test/solidlsp/terraform/test_terraform_basic.py @@ -5,18 +5,16 @@ These tests validate the functionality of the language server APIs like request_references using the test repository. """ -import shutil - import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import is_ci +from test.conftest import language_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -@pytest.mark.skipif(shutil.which("terraform") is None and not is_ci, reason="Terraform CLI is not available") +@pytest.mark.skipif(not language_tests_enabled(Language.TERRAFORM), reason="Terraform tests are disabled (terraform CLI not available)") @pytest.mark.terraform class TestLanguageServerBasics: """Test basic functionality of the Terraform language server.""" From 4b7e02239aa0a05f6e61e69e0185fef83b7c39d3 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 18:28:36 +0200 Subject: [PATCH 045/170] CI: re-adding ubuntu to OS matrix and niche languages with large toolkits --- .github/workflows/pytest.yml | 145 +++++++++++++++++++++++++++++++---- 1 file changed, 129 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 3aa59854..b06c99cc 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -28,24 +28,32 @@ jobs: MARKERS_SWIFT: "swift" # split out: swiftly toolchain install is the slowest setup, isolate + cache it MARKERS_WEB: "typescript or svelte or vue or angular or html or scss or json or solidity or elm" MARKERS_PYTHON: "python" - MARKERS_SCRIPTING: "ruby or php or perl or lua or luau or bash or powershell or elixir or erlang or dart or haxe" - MARKERS_OTHER_LANGS: "terraform or rego or ansible or yaml or toml or markdown or latex or nix or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al or ocaml or haskell or lean4 or julia or r" + MARKERS_SCRIPTING: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or haxe" + MARKERS_OTHER_LANGS: "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" + # Slow / heavy toolchains, each isolated in its own batch (OS gating via the matrix excludes). + MARKERS_HASKELL: "haskell" + MARKERS_OCAML: "ocaml" + MARKERS_NIX: "nix" + MARKERS_LEAN: "lean4" + MARKERS_NICHE: "julia or r or perl" # lighter niche toolchains grouped together strategy: fail-fast: false matrix: - os: [windows-latest, macos-latest] + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11"] # Test execution is split into parallel batches by pytest marker. Each batch installs ONLY - # the toolchains its languages need -- steps below are gated with `if: matrix.batch == ...` - # -- so e.g. Swift/Lean are set up only in the batch that runs their tests, not in all 7. + # the toolchains its languages need -- steps below are gated with `if: matrix.batch == ...`. # Node/Python/uv stay in every batch (cheap, and needed for the venv + auto-installed LSes). # "catch-all" carries the unmarked core-framework tests in test/serena and needs no language - # toolchains. Rebalance once per-batch timings show up. - batch: [jvm, native, swift, web, python, scripting, other-langs, catch-all] - # Swift only runs on macOS (disabled on Windows via the central guard), so skip windows x swift. + # toolchains. haskell/ocaml/nix/lean are slow toolchains isolated in their own batches. + batch: [jvm, native, swift, web, python, scripting, other-langs, haskell, ocaml, nix, lean, niche, catch-all] exclude: - - os: windows-latest - batch: swift + # swift is macOS-only (swiftly toolchain + cache; its Linux path has never actually run) + - { os: ubuntu-latest, batch: swift } + - { os: windows-latest, batch: swift } + # haskell / nix have no native Windows support (matches the original `runner.os != 'Windows'`) + - { os: windows-latest, batch: haskell } + - { os: windows-latest, batch: nix } steps: - uses: actions/checkout@v4 - name: Free disk space @@ -509,10 +517,110 @@ jobs: echo "ERROR: FPCDIR does not exist: $FPCDIR" exit 1 fi - # Lean 4 is intentionally NOT installed/built here: the toolchain is ~1 GB (it *is* the language - # server) and lean4 is already covered by the Linux maximal container (test-parallel.yml's niche - # batch). With no `lean` on PATH, the central language_tests_enabled(Language.LEAN4) guard simply - # skips lean4 on the Windows/macOS runners -- no per-OS toolchain download. + # --- Slow / heavy language toolchains, each isolated in its own batch (OS gating via matrix excludes) --- + - name: Setup Haskell toolchain + if: matrix.batch == 'haskell' + uses: haskell/ghcup-setup@v1 + with: + ghc: '9.12.2' + cabal: '3.10.3.0' + hls: '2.11.0.0' + - name: Verify Haskell tools + if: matrix.batch == 'haskell' + shell: bash + run: | + which ghc && ghc --version + which cabal && cabal --version + if command -v haskell-language-server-wrapper &>/dev/null; then + haskell-language-server-wrapper --version || echo "WARNING: HLS wrapper version check failed" + else + echo "WARNING: haskell-language-server-wrapper not found (may be incompatible with GHC 9.12.2)" + fi + - name: Pre-build Haskell test project for HLS + if: matrix.batch == 'haskell' + shell: bash + run: | + cd test/resources/repos/haskell/test_repo + cabal update + cabal build --only-dependencies + cabal build + - name: Install OCaml and opam + if: matrix.batch == 'ocaml' + uses: ocaml/setup-ocaml@v3 + with: + ocaml-compiler: ${{ runner.os == 'Windows' && '4.14' || '5.3.x' }} + dune-cache: true + opam-repositories: | + ${{ runner.os == 'Windows' && 'opam-repository-mingw: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset' || '' }} + default: https://github.com/ocaml/opam-repository.git + - name: Install OCaml packages + if: matrix.batch == 'ocaml' + shell: bash + run: | + if [ "$RUNNER_OS" = "Windows" ]; then + opam install -y dune ocaml-lsp-server + else + opam install -y dune 'ocaml-lsp-server>=1.23.0' + fi + - name: Install Nix + if: matrix.batch == 'nix' + uses: cachix/install-nix-action@v30 + with: + nix_path: nixpkgs=channel:nixos-unstable + - name: Install nixd (Nix Language Server) + if: matrix.batch == 'nix' + shell: bash + run: | + nix profile install github:nix-community/nixd + if ! command -v nixd &> /dev/null; then + echo "nixd installation failed or not in PATH"; exit 1 + fi + echo "$HOME/.nix-profile/bin" >> $GITHUB_PATH + - name: Build Lean 4 test project + if: matrix.batch == 'lean' + uses: leanprover/lean-action@v1 + with: + lake-package-directory: test/resources/repos/lean4/test_repo + # --- niche batch: R, Julia, Perl --- + - name: Install R + if: matrix.batch == 'niche' + uses: r-lib/actions/setup-r@v2 + with: + r-version: '4.4.2' + use-public-rspm: true + - name: Install R sysdeps (libuv for fs package) + if: matrix.batch == 'niche' && runner.os == 'Linux' + shell: bash + run: sudo apt-get update && sudo apt-get install -y libuv1-dev + - name: Install R language server + if: matrix.batch == 'niche' + shell: bash + run: Rscript -e "install.packages('languageserver', repos='https://cloud.r-project.org')" + - name: Set up Julia + if: matrix.batch == 'niche' + uses: julia-actions/setup-julia@v2 + with: + version: '1.12.6' + - name: Install Julia LanguageServer + if: matrix.batch == 'niche' + shell: bash + run: julia -e 'using Pkg; Pkg.add(Pkg.PackageSpec(name="LanguageServer", version="5.0.0"))' + - name: Install Perl::LanguageServer + if: matrix.batch == 'niche' && runner.os != 'Windows' + shell: bash + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y cpanminus build-essential libanyevent-perl libio-aio-perl + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install cpanminus + fi + PERL_MM_USE_DEFAULT=1 cpanm --notest --force Perl::LanguageServer + echo "PERL5LIB=$HOME/perl5/lib/perl5${PERL5LIB:+:${PERL5LIB}}" >> $GITHUB_ENV + echo "PERL_LOCAL_LIB_ROOT=$HOME/perl5${PERL_LOCAL_LIB_ROOT:+:${PERL_LOCAL_LIB_ROOT}}" >> $GITHUB_ENV + echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV + echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV + echo "$HOME/perl5/bin" >> $GITHUB_PATH - name: Install npm deps for Angular test repo if: matrix.batch == 'web' shell: bash @@ -546,7 +654,12 @@ jobs: python) expr="$MARKERS_PYTHON" ;; scripting) expr="$MARKERS_SCRIPTING" ;; other-langs) expr="$MARKERS_OTHER_LANGS" ;; - catch-all) expr="not ($MARKERS_JVM or $MARKERS_NATIVE or $MARKERS_SWIFT or $MARKERS_WEB or $MARKERS_PYTHON or $MARKERS_SCRIPTING or $MARKERS_OTHER_LANGS)" ;; + haskell) expr="$MARKERS_HASKELL" ;; + ocaml) expr="$MARKERS_OCAML" ;; + nix) expr="$MARKERS_NIX" ;; + lean) expr="$MARKERS_LEAN" ;; + niche) expr="$MARKERS_NICHE" ;; + catch-all) expr="not ($MARKERS_JVM or $MARKERS_NATIVE or $MARKERS_SWIFT or $MARKERS_WEB or $MARKERS_PYTHON or $MARKERS_SCRIPTING or $MARKERS_OTHER_LANGS or $MARKERS_HASKELL or $MARKERS_OCAML or $MARKERS_NIX or $MARKERS_LEAN or $MARKERS_NICHE)" ;; *) echo "Unknown batch: ${{ matrix.batch }}" >&2; exit 1 ;; esac echo "Marker expression: $expr" @@ -554,7 +667,7 @@ jobs: - name: Test with pytest shell: bash run: uv run poe test -m "${{ steps.markers.outputs.expr }}" -q --tb=short - - name: Type-checking with mypy + - name: Type-checking with ty # Type-checking is batch-independent; run it only once per OS instead of in every batch. if: matrix.batch == 'catch-all' shell: bash From ad7f65ef1387fe34621143ff2c2d0d807012cac7 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 19:04:04 +0200 Subject: [PATCH 046/170] CI: improve distribution of cases to batches (GH offers limited concurrency) See https://docs.github.com/en/actions/reference/limits#job-concurrency-limits-for-github-hosted-runners --- .github/workflows/pytest.yml | 99 ++++++++++-------------------------- test/conftest.py | 6 ++- 2 files changed, 32 insertions(+), 73 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b06c99cc..b54c6f77 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -24,36 +24,22 @@ jobs: # group) add a case branch below + the batch name to the matrix list. env: MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl" - MARKERS_NATIVE: "cpp or rust or go or zig or pascal" - MARKERS_SWIFT: "swift" # split out: swiftly toolchain install is the slowest setup, isolate + cache it - MARKERS_WEB: "typescript or svelte or vue or angular or html or scss or json or solidity or elm" - MARKERS_PYTHON: "python" - MARKERS_SCRIPTING: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or haxe" - MARKERS_OTHER_LANGS: "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" - # Slow / heavy toolchains, each isolated in its own batch (OS gating via the matrix excludes). - MARKERS_HASKELL: "haskell" - MARKERS_OCAML: "ocaml" - MARKERS_NIX: "nix" - MARKERS_LEAN: "lean4" - MARKERS_NICHE: "julia or r or perl" # lighter niche toolchains grouped together + MARKERS_NATIVE: "cpp or rust or go or zig or pascal or swift" # swift is macOS-only (skips elsewhere via the guard) + # other-langs: grab-bag + scripting languages + haskell (haskell skips on Windows via the 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" + # 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" strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11"] - # Test execution is split into parallel batches by pytest marker. Each batch installs ONLY - # the toolchains its languages need -- steps below are gated with `if: matrix.batch == ...`. - # Node/Python/uv stay in every batch (cheap, and needed for the venv + auto-installed LSes). - # "catch-all" carries the unmarked core-framework tests in test/serena and needs no language - # toolchains. haskell/ocaml/nix/lean are slow toolchains isolated in their own batches. - batch: [jvm, native, swift, web, python, scripting, other-langs, haskell, ocaml, nix, lean, niche, catch-all] - exclude: - # swift is macOS-only (swiftly toolchain + cache; its Linux path has never actually run) - - { os: ubuntu-latest, batch: swift } - - { os: windows-latest, batch: swift } - # haskell / nix have no native Windows support (matches the original `runner.os != 'Windows'`) - - { os: windows-latest, batch: haskell } - - { os: windows-latest, batch: nix } + # 5 batches, each installs ONLY the toolchains its languages need (steps gated by matrix.batch). + # Node/Python/uv stay in every batch (cheap, needed for the venv + auto-installed LSes). + # OS-specific languages skip via the central guards (swift -> macOS only; haskell/nix/perl -> + # not Windows), so no matrix excludes are needed. catch-all = web + python + the core framework. + batch: [jvm, native, other-langs, niche, catch-all] steps: - uses: actions/checkout@v4 - name: Free disk space @@ -196,7 +182,7 @@ jobs: # We can try again when version 3 of the action is released, where they will also use swiftly # Until then, we use custom code to install swift. Sourcekit-lsp is installed automatically with swift - name: Cache Swift toolchain (swiftly) - if: runner.os == 'macOS' && matrix.batch == 'swift' + if: runner.os == 'macOS' && matrix.batch == 'native' id: cache-swiftly uses: actions/cache@v3 with: @@ -207,7 +193,7 @@ jobs: ~/Library/Developer/Toolchains key: swiftly-${{ runner.os }}-6.1.2-v2 - name: Install Swift with swiftly (macOS) - if: runner.os == 'macOS' && matrix.batch == 'swift' + if: runner.os == 'macOS' && matrix.batch == 'native' run: | set -e # Install swiftly itself only if its binary isn't already present (e.g. restored from cache). @@ -233,34 +219,13 @@ jobs: fi echo "sourcekit-lsp: $SK" dirname "$SK" >> "$GITHUB_PATH" - - name: Install Swift with swiftly (Ubuntu) - if: runner.os == 'Linux' && matrix.batch == 'swift' - run: | - echo "=== Installing swiftly on Ubuntu ===" - # Install dependencies BEFORE Swift to avoid exit code 1 - sudo apt-get update - sudo apt-get -y install libcurl4-openssl-dev - curl -O https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz && \ - tar zxf swiftly-$(uname -m).tar.gz && \ - ./swiftly init --quiet-shell-followup && \ - . "${SWIFTLY_HOME_DIR:-$HOME/.local/share/swiftly}/env.sh" && \ - hash -r - swiftly install --use 6.1.2 - swiftly use 6.1.2 - echo "=== Adding Swift toolchain to PATH ===" - echo "$HOME/.local/share/swiftly/bin" >> $GITHUB_PATH - echo "Swiftly installed successfully!" - # Verify sourcekit-lsp is working before proceeding - echo "=== Verifying sourcekit-lsp installation ===" - which sourcekit-lsp || echo "Warning: sourcekit-lsp not found in PATH" - sourcekit-lsp --help || echo "Warning: sourcekit-lsp not responding" - name: Install Ruby - if: matrix.batch == 'scripting' + if: matrix.batch == 'other-langs' uses: ruby/setup-ruby@v1 with: ruby-version: '3.4' - name: Install Ruby language server - if: matrix.batch == 'scripting' + if: matrix.batch == 'other-langs' shell: bash run: gem install ruby-lsp - name: Install Zig @@ -327,7 +292,7 @@ jobs: echo "WARNING: verible-verilog-ls not found in PATH" fi - name: Install Lua Language Server - if: matrix.batch == 'scripting' + if: matrix.batch == 'other-langs' shell: bash run: | LUA_LS_VERSION="3.15.0" @@ -377,14 +342,14 @@ jobs: shell: bash run: uv run pip install ansible-core ansible-lint - name: Install Haxe - if: matrix.batch == 'scripting' + if: matrix.batch == 'other-langs' # The Haxe LS binary (server.js) is auto-downloaded and runs on Node.js, # but it delegates to the Haxe compiler for code analysis at runtime. uses: krdlab/setup-haxe@v2 with: haxe-version: 4.3.7 - name: Install Elm - if: matrix.batch == 'web' + if: matrix.batch == 'catch-all' shell: bash run: npm install -g elm@0.19.1-6 - name: Install Regal (Rego Language Server) @@ -519,14 +484,14 @@ jobs: fi # --- Slow / heavy language toolchains, each isolated in its own batch (OS gating via matrix excludes) --- - name: Setup Haskell toolchain - if: matrix.batch == 'haskell' + if: matrix.batch == 'other-langs' && runner.os != 'Windows' uses: haskell/ghcup-setup@v1 with: ghc: '9.12.2' cabal: '3.10.3.0' hls: '2.11.0.0' - name: Verify Haskell tools - if: matrix.batch == 'haskell' + if: matrix.batch == 'other-langs' && runner.os != 'Windows' shell: bash run: | which ghc && ghc --version @@ -537,7 +502,7 @@ jobs: echo "WARNING: haskell-language-server-wrapper not found (may be incompatible with GHC 9.12.2)" fi - name: Pre-build Haskell test project for HLS - if: matrix.batch == 'haskell' + if: matrix.batch == 'other-langs' && runner.os != 'Windows' shell: bash run: | cd test/resources/repos/haskell/test_repo @@ -545,7 +510,7 @@ jobs: cabal build --only-dependencies cabal build - name: Install OCaml and opam - if: matrix.batch == 'ocaml' + if: matrix.batch == 'niche' uses: ocaml/setup-ocaml@v3 with: ocaml-compiler: ${{ runner.os == 'Windows' && '4.14' || '5.3.x' }} @@ -554,7 +519,7 @@ jobs: ${{ runner.os == 'Windows' && 'opam-repository-mingw: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset' || '' }} default: https://github.com/ocaml/opam-repository.git - name: Install OCaml packages - if: matrix.batch == 'ocaml' + if: matrix.batch == 'niche' shell: bash run: | if [ "$RUNNER_OS" = "Windows" ]; then @@ -563,12 +528,12 @@ jobs: opam install -y dune 'ocaml-lsp-server>=1.23.0' fi - name: Install Nix - if: matrix.batch == 'nix' + if: matrix.batch == 'niche' && runner.os != 'Windows' uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-unstable - name: Install nixd (Nix Language Server) - if: matrix.batch == 'nix' + if: matrix.batch == 'niche' && runner.os != 'Windows' shell: bash run: | nix profile install github:nix-community/nixd @@ -577,7 +542,7 @@ jobs: fi echo "$HOME/.nix-profile/bin" >> $GITHUB_PATH - name: Build Lean 4 test project - if: matrix.batch == 'lean' + if: matrix.batch == 'niche' uses: leanprover/lean-action@v1 with: lake-package-directory: test/resources/repos/lean4/test_repo @@ -622,7 +587,7 @@ jobs: echo "PERL_MM_OPT=INSTALL_BASE=$HOME/perl5" >> $GITHUB_ENV echo "$HOME/perl5/bin" >> $GITHUB_PATH - name: Install npm deps for Angular test repo - if: matrix.batch == 'web' + if: matrix.batch == 'catch-all' shell: bash run: | if [ -d test/resources/repos/angular/test_repo ]; then @@ -649,17 +614,9 @@ jobs: case "${{ matrix.batch }}" in jvm) expr="$MARKERS_JVM" ;; native) expr="$MARKERS_NATIVE" ;; - swift) expr="$MARKERS_SWIFT" ;; - web) expr="$MARKERS_WEB" ;; - python) expr="$MARKERS_PYTHON" ;; - scripting) expr="$MARKERS_SCRIPTING" ;; other-langs) expr="$MARKERS_OTHER_LANGS" ;; - haskell) expr="$MARKERS_HASKELL" ;; - ocaml) expr="$MARKERS_OCAML" ;; - nix) expr="$MARKERS_NIX" ;; - lean) expr="$MARKERS_LEAN" ;; niche) expr="$MARKERS_NICHE" ;; - catch-all) expr="not ($MARKERS_JVM or $MARKERS_NATIVE or $MARKERS_SWIFT or $MARKERS_WEB or $MARKERS_PYTHON or $MARKERS_SCRIPTING or $MARKERS_OTHER_LANGS or $MARKERS_HASKELL or $MARKERS_OCAML or $MARKERS_NIX or $MARKERS_LEAN or $MARKERS_NICHE)" ;; + catch-all) expr="not ($MARKERS_JVM or $MARKERS_NATIVE or $MARKERS_OTHER_LANGS or $MARKERS_NICHE)" ;; *) echo "Unknown batch: ${{ matrix.batch }}" >&2; exit 1 ;; esac echo "Marker expression: $expr" diff --git a/test/conftest.py b/test/conftest.py index 34487aa7..a8cf3394 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -243,6 +243,7 @@ Flag indicating whether the tests are running in the GitHub CI environment. """ is_windows = platform.system() == "Windows" +is_macos = platform.system() == "Darwin" _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { @@ -400,8 +401,9 @@ def _determine_disabled_languages() -> list[Language]: # === 3. Disabled wherever the precondition is missing (including on CI) === # 3a. Platform precondition: these language servers have no native Windows support. if is_windows: - result.append(Language.ANSIBLE) - result.append(Language.SWIFT) + result.append(Language.ANSIBLE) # ansible-language-server has no native Windows support + if not is_macos: + result.append(Language.SWIFT) # swiftly toolchain is only set up on the macOS native batch # 3b. Toolchain / language-server availability (the LS/compiler must be on PATH or installed). if _sh.which("clangd") is None: result.append(Language.CPP) From 4246ac0fe8ee60abdc274987fdfe17b4b8df9bfa Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 19:08:45 +0200 Subject: [PATCH 047/170] CI: remove unnecessary free disk space step --- .github/workflows/pytest.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index b54c6f77..c13ac829 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -42,18 +42,6 @@ jobs: batch: [jvm, native, other-langs, niche, catch-all] steps: - uses: actions/checkout@v4 - - name: Free disk space - if: runner.os == 'Linux' - run: | - df -h - sudo rm -rf /usr/local/lib/android - sudo rm -rf /usr/share/dotnet - sudo rm -rf /opt/ghc - sudo rm -rf /opt/hostedtoolcache - sudo apt-get clean - sudo apt-get autoremove -y - docker system prune -af || true - df -h - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -602,11 +590,6 @@ jobs: key: language-servers-${{ runner.os }}-${{ matrix.batch }}-v1 restore-keys: | language-servers-${{ runner.os }}-${{ matrix.batch }}- - - name: Report free disk space - if: runner.os == 'Linux' - run: | - echo "Free disk space before tests:" - df -h - name: Compute marker expression for this batch id: markers shell: bash From af1048901db85e21fa6b13ab2d821eb163c1f297 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 21:44:55 +0200 Subject: [PATCH 048/170] CI: run niche batch only on ubuntu and document workflow logic --- .github/workflows/pytest.yml | 53 ++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c13ac829..ce3387cd 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,3 +1,42 @@ +# ============================================================================= +# Test CI -- strategy & rationale (details inline at each step) +# ============================================================================= +# BATCHING / PARALLELISM +# Tests run as parallel matrix jobs over (os x batch). They are split into 5 +# marker-based batches (jvm, native, other-langs, niche, catch-all). We batch by +# pytest MARKER, not pytest-xdist (`-n`): tests share fixtures, language-server +# processes and on-disk resources, so in-process parallelism would race them. +# Each batch installs ONLY the toolchains its languages need (every install step +# is gated on matrix.batch), keeping jobs lean. catch-all is the negation of all +# named marker groups, so every test lands in exactly one batch and the partition +# cannot desync when a language is added. +# +# OS GATING +# Default is the full (os x batch) cross-product. `niche` (heavy/slow toolchains: +# lean, ocaml, nix, julia, R, perl) is ubuntu-only via matrix.exclude -- not worth +# the Windows/macOS cost. An OS-specific *language* inside an otherwise all-OS +# batch (swift -> macOS, haskell -> not Windows) is skipped one level down by the +# central conftest guard, NOT by the matrix. +# +# CONCURRENCY LIMITS (GitHub free tier) +# Hard caps: 20 concurrent jobs total and 5 concurrent macOS jobs. These bound the +# batch count -- the current matrix is 13 jobs (4 of them macOS), inside both +# limits; adding batches/OSes must keep respecting these ceilings or jobs queue. +# `concurrency: cancel-in-progress` also kills superseded runs on the same ref to +# free capacity quickly. +# +# CACHING +# Per-batch language-server cache keyed by os+batch (language-servers-<os>-<batch>), +# plus dedicated caches for the uv venv (keyed on uv.lock), swiftly, ZLS, FPC and +# the Go build/bin. Most niche toolchains restore from cache, so that batch is fast +# despite its size. +# +# TESTING / SKIPS +# All language skip logic is centralized in test/conftest.py +# (`language_tests_enabled()`): a test is skipped when its toolchain is not expected +# on the current OS/CI. Lint and type-check are batch-independent and run once per +# OS (catch-all only), not in every batch. +# ============================================================================= name: Tests on: @@ -37,9 +76,19 @@ jobs: python-version: ["3.11"] # 5 batches, each installs ONLY the toolchains its languages need (steps gated by matrix.batch). # Node/Python/uv stay in every batch (cheap, needed for the venv + auto-installed LSes). - # OS-specific languages skip via the central guards (swift -> macOS only; haskell/nix/perl -> - # not Windows), so no matrix excludes are needed. catch-all = web + python + the core framework. + # catch-all = web + python + the core framework. batch: [jvm, native, other-langs, niche, catch-all] + # --- OS gating: which batch runs on which OS ----------------------------------------- + # Default is the full cross-product (every batch on all three OSes); the `exclude` entries + # below carve out the exceptions. An OS-specific *language* inside an otherwise all-OS batch + # is handled one level down by the central conftest guard (swift -> macOS only; haskell -> + # not Windows), NOT here -- excludes drop a WHOLE batch from an OS. + # jvm, native, other-langs, catch-all -> ubuntu, windows, macOS + # niche -> ubuntu only (heavy/slow toolchains -- lean, ocaml, + # nix, julia, R, perl -- not worth the Win/macOS cost) + exclude: + - { os: windows-latest, batch: niche } + - { os: macos-latest, batch: niche } steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} From ebe7646764419211fba7f701c15e648074cd5858 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 28 Jun 2026 22:11:30 +0200 Subject: [PATCH 049/170] CI: overcome rate-limits for GH downloads by providing access token Also only run haskell tests on linux, toolchain takes 6 mins to install on macos --- .github/workflows/pytest.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index ce3387cd..6da4aef0 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -15,7 +15,7 @@ # Default is the full (os x batch) cross-product. `niche` (heavy/slow toolchains: # lean, ocaml, nix, julia, R, perl) is ubuntu-only via matrix.exclude -- not worth # the Windows/macOS cost. An OS-specific *language* inside an otherwise all-OS -# batch (swift -> macOS, haskell -> not Windows) is skipped one level down by the +# batch (swift -> macOS, haskell -> Linux only) is skipped one level down by the # central conftest guard, NOT by the matrix. # # CONCURRENCY LIMITS (GitHub free tier) @@ -64,7 +64,8 @@ jobs: env: MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl" MARKERS_NATIVE: "cpp or rust or go or zig or pascal or swift" # swift is macOS-only (skips elsewhere via the guard) - # other-langs: grab-bag + scripting languages + haskell (haskell skips on Windows via the guard) + # other-langs: grab-bag + scripting languages + haskell. Haskell (GHC/HLS) is installed only on + # Linux -- it's too slow/expensive to build on the macOS runner -- and skips on Windows+macOS via the 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" # 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. @@ -521,14 +522,14 @@ jobs: fi # --- Slow / heavy language toolchains, each isolated in its own batch (OS gating via matrix excludes) --- - name: Setup Haskell toolchain - if: matrix.batch == 'other-langs' && runner.os != 'Windows' + if: matrix.batch == 'other-langs' && runner.os == 'Linux' uses: haskell/ghcup-setup@v1 with: ghc: '9.12.2' cabal: '3.10.3.0' hls: '2.11.0.0' - name: Verify Haskell tools - if: matrix.batch == 'other-langs' && runner.os != 'Windows' + if: matrix.batch == 'other-langs' && runner.os == 'Linux' shell: bash run: | which ghc && ghc --version @@ -539,7 +540,7 @@ jobs: echo "WARNING: haskell-language-server-wrapper not found (may be incompatible with GHC 9.12.2)" fi - name: Pre-build Haskell test project for HLS - if: matrix.batch == 'other-langs' && runner.os != 'Windows' + if: matrix.batch == 'other-langs' && runner.os == 'Linux' shell: bash run: | cd test/resources/repos/haskell/test_repo @@ -569,11 +570,22 @@ jobs: uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-unstable + # Authenticate any GitHub fetch Nix makes during evaluation/tests with the runner's + # GITHUB_TOKEN (raises the unauthenticated 60 req/hr-per-IP API limit to ~1000/hr). + # nixd itself is installed from the channel below, so this is just insurance. + extra_nix_config: | + access-tokens = github.com=${{ secrets.GITHUB_TOKEN }} - name: Install nixd (Nix Language Server) if: matrix.batch == 'niche' && runner.os != 'Windows' shell: bash run: | - nix profile install github:nix-community/nixd + # Install nixd from the nixos-unstable channel (set as <nixpkgs> on NIX_PATH via nix_path + # above) using classic nix-env. `-f '<nixpkgs>'` resolves the channel tarball from + # nixos.org and selects the `nixd` attribute -- it never touches the GitHub API. (The flake + # form `nix profile install github:nix-community/nixd` resolves an unpinned HEAD through + # api.github.com/.../commits/HEAD, which GitHub 429-throttles on CI even with a token; + # and `nix-env -iA nixpkgs.nixd` fails here because no channel named `nixpkgs` is registered.) + nix-env -f '<nixpkgs>' -iA nixd if ! command -v nixd &> /dev/null; then echo "nixd installation failed or not in PATH"; exit 1 fi From 85e78db6700797e83de2381b9eda15cb305c6c20 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Mon, 29 Jun 2026 10:13:33 +0200 Subject: [PATCH 050/170] CI: remove now obsolete Dockerfile.maximal-based workflow --- .github/workflows/build-maximal-image.yml | 43 ------ .github/workflows/test-parallel.yml | 105 -------------- Dockerfile.maximal | 164 ---------------------- 3 files changed, 312 deletions(-) delete mode 100644 .github/workflows/build-maximal-image.yml delete mode 100644 .github/workflows/test-parallel.yml delete mode 100644 Dockerfile.maximal diff --git a/.github/workflows/build-maximal-image.yml b/.github/workflows/build-maximal-image.yml deleted file mode 100644 index b4b4ef8d..00000000 --- a/.github/workflows/build-maximal-image.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Build and Publish Maximal Image - -on: - push: - branches: - - main - paths: - - 'Dockerfile.maximal' - - '.github/workflows/build-maximal-image.yml' - workflow_dispatch: - -permissions: - contents: read - packages: write - -jobs: - build-and-push: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Log in to the Container registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository }}-maximal - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile.maximal - push: true - tags: ghcr.io/${{ github.repository }}-maximal:latest - labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/test-parallel.yml b/.github/workflows/test-parallel.yml deleted file mode 100644 index f9a289ab..00000000 --- a/.github/workflows/test-parallel.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Parallel Matrix CI - -on: - push: - branches: - - main - pull_request: - branches: - - main - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - container: - image: ghcr.io/${{ github.repository }}-maximal:latest - - strategy: - fail-fast: false - matrix: - include: - - batch_name: "Heavy Toolchains (C++, Rust, Java)" - marker_expr: 'cpp or rust or java' - - - batch_name: "Medium Toolchains (Go)" - marker_expr: 'go' - - - batch_name: "Catch-All (Core Framework & Remaining Languages)" - marker_expr: 'not cpp and not rust and not java and not go and not elm and not r and not rego and not scala' - - name: ${{ matrix.batch_name }} - - env: - UV_LINK_MODE: copy - - steps: - - uses: actions/checkout@v4 - - - name: Cache language servers - uses: actions/cache@v4 - with: - path: ~/.serena/language_servers - key: ls-cache-${{ runner.os }}-${{ github.sha }} - restore-keys: | - ls-cache-${{ runner.os }}- - - - name: Setup uv - uses: astral-sh/setup-uv@v3 - with: - enable-cache: true - cache-dependency-glob: | - pyproject.toml - - - name: Create venv + install deps - shell: bash - run: | - set -euxo pipefail - uv venv .venv - .venv/bin/python -m ensurepip --upgrade || true - .venv/bin/python -m pip install -U pip setuptools wheel - uv pip install -e ".[dev]" - .venv/bin/python -m pytest --version - - - name: Dotnet diagnostics (non-blocking) - shell: bash - run: | - set -euxo pipefail - dotnet --list-runtimes || true - dotnet --info || true - - - name: Compute marker expression (final architecture quarantine) - id: markers - shell: bash - run: | - set -euo pipefail - MARKERS='${{ matrix.marker_expr }}' - - if [[ '${{ matrix.batch_name }}' == 'Catch-All (Core Framework & Remaining Languages)' ]]; then - # Strict Quarantining: Explicitly disabling flaky auto-install dependencies & missing heavy toolchains - MARKERS="$MARKERS and not ruby and not php and not clojure and not kotlin and not msl and not nix and not zig and not swift and not perl and not fortran and not haskell and not elixir and not fsharp and not groovy and not matlab and not yaml and not json and not ansible and not julia and not lean4 and not ocaml and not pascal and not powershell and not svelte and not csharp" - fi - - echo "MARKERS=$MARKERS" - echo "markers=$MARKERS" >> "$GITHUB_OUTPUT" - - - name: Run pytest - shell: bash - run: | - set -euxo pipefail - echo "Batch: ${{ matrix.batch_name }}" - echo "Running: pytest -m '${{ steps.markers.outputs.markers }}'" - - # Catch-All only: deselect known flaky tests and environment-dependent GUI tests - if [[ '${{ matrix.batch_name }}' == 'Catch-All (Core Framework & Remaining Languages)' ]]; then - .venv/bin/python -m pytest \ - -m "${{ steps.markers.outputs.markers }}" \ - -k "not test_find_symbol_references_stable and not test_show_fatal_exception_safe" \ - --maxfail=1 -q - else - .venv/bin/python -m pytest \ - -m "${{ steps.markers.outputs.markers }}" \ - --maxfail=1 -q - fi \ No newline at end of file diff --git a/Dockerfile.maximal b/Dockerfile.maximal deleted file mode 100644 index 010ec2be..00000000 --- a/Dockerfile.maximal +++ /dev/null @@ -1,164 +0,0 @@ -# Base stage with common dependencies -FROM python:3.11-slim AS base -SHELL ["/bin/bash", "-c"] - -# Set environment variables to make Python print directly to the terminal and avoid .pyc files. -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 - -# Install system dependencies required for package manager and build tools. -# Notes: -# - python3-venv: makes venv/ensurepip reliable -# - locales + libicu: required for dotnet to run on slim images -RUN apt-get update && apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - build-essential \ - git \ - ssh \ - sudo \ - wget \ - zip \ - unzip \ - sed \ - python3-venv \ - locales \ - libicu-dev \ - && rm -rf /var/lib/apt/lists/* - -# Configure UTF-8 locale (helps dotnet and some language servers) -RUN sed -i 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ - && locale-gen -ENV LANG=en_US.UTF-8 -ENV LC_ALL=en_US.UTF-8 - -# Install pipx -RUN python3 -m pip install --no-cache-dir pipx \ - && pipx ensurepath - -# Install nodejs via NVM -ENV NVM_VERSION=0.40.3 -ENV NODE_VERSION=22.18.0 -RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash -ENV NVM_DIR=/root/.nvm -RUN . "$NVM_DIR/nvm.sh" && nvm install ${NODE_VERSION} \ - && . "$NVM_DIR/nvm.sh" && nvm use v${NODE_VERSION} \ - && . "$NVM_DIR/nvm.sh" && nvm alias default v${NODE_VERSION} -ENV PATH="${NVM_DIR}/versions/node/v${NODE_VERSION}/bin/:${PATH}" - -# Add local bin to the path -ENV PATH="${PATH}:/root/.local/bin" - -# Install uv (available as `uv` binary) -RUN curl -LsSf https://astral.sh/uv/install.sh | sh - -# Install Rust and rustup for rust-analyzer support (minimal profile) -ENV RUSTUP_HOME=/usr/local/rustup -ENV CARGO_HOME=/usr/local/cargo -ENV PATH="${CARGO_HOME}/bin:${PATH}" -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ - --default-toolchain stable \ - --profile minimal \ - && rustup component add rust-analyzer - -# ============================================================================== -# MAXIMAL ADDITIONS (For Catch-All CI Testing) -# ============================================================================== - -# 1. Install Ruby, Java (JDK & JRE), Scala, Haxe, Nix, and Clangd (C++) via apt -RUN apt-get update && apt-get install -y --no-install-recommends \ - ruby-full \ - default-jre \ - default-jdk \ - scala \ - haxe \ - nix-bin \ - clangd \ - && rm -rf /var/lib/apt/lists/* - -# Install Ruby language server (needed for ruby tests) -RUN gem install ruby-lsp - -# 1.25 Install Lean 4 via elan (required for lean4 tests) -ENV ELAN_HOME=/root/.elan -ENV PATH="${ELAN_HOME}/bin:${PATH}" -RUN curl -sSf https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh | sh -s -- -y \ - && elan default stable \ - && lean --version - -# 1.5 Install Julia officially via pre-compiled binaries -RUN curl -L -o /tmp/julia.tar.gz https://julialang-s3.julialang.org/bin/linux/x64/1.10/julia-1.10.4-linux-x86_64.tar.gz \ - && mkdir -p /opt/julia \ - && tar zxf /tmp/julia.tar.gz -C /opt/julia --strip-components=1 \ - && ln -s /opt/julia/bin/julia /usr/local/bin/julia \ - && rm /tmp/julia.tar.gz - -# 2. Install PowerShell (pwsh) -RUN curl -L -o /tmp/powershell.tar.gz https://github.com/PowerShell/PowerShell/releases/download/v7.4.2/powershell-7.4.2-linux-x64.tar.gz \ - && mkdir -p /opt/microsoft/powershell/7 \ - && tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 \ - && chmod +x /opt/microsoft/powershell/7/pwsh \ - && ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \ - && rm /tmp/powershell.tar.gz - -# 3. Install .NET (stable, for Roslyn language server) -ENV DOTNET_ROOT=/root/.dotnet -ENV PATH="${DOTNET_ROOT}:${PATH}" -RUN curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ - && chmod +x /tmp/dotnet-install.sh \ - && /tmp/dotnet-install.sh --channel 10.0 --install-dir "${DOTNET_ROOT}" \ - && /tmp/dotnet-install.sh --channel 10.0 --runtime dotnet --install-dir "${DOTNET_ROOT}" \ - && ln -sf "${DOTNET_ROOT}/dotnet" /usr/bin/dotnet \ - && rm -f /tmp/dotnet-install.sh - -# 4. Install Go and gopls globally in system paths -ENV GO_VERSION=1.22.3 -RUN curl -L -o /tmp/go.tar.gz https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz \ - && tar -C /usr/local -xzf /tmp/go.tar.gz \ - && rm /tmp/go.tar.gz -ENV PATH="/usr/local/go/bin:${PATH}" -RUN go install golang.org/x/tools/gopls@latest \ - && mv /root/go/bin/gopls /usr/local/bin/gopls - -# 5. Install Terraform -ENV TERRAFORM_VERSION=1.8.4 -RUN curl -L -o /tmp/terraform.zip https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ - && unzip /tmp/terraform.zip -d /usr/local/bin/ \ - && rm /tmp/terraform.zip - -# 6. Install JS Ecosystem Language Servers (Typescript, Vue, Bash, HTML/CSS) -RUN npm install -g \ - typescript \ - typescript-language-server \ - @vue/language-server \ - bash-language-server \ - vscode-langservers-extracted - -# 7. Install Java Language Server (jdtls) snapshot with global system binary wrapper -RUN wget https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz -O /tmp/jdtls.tar.gz \ - && mkdir -p /opt/jdtls \ - && tar -xzf /tmp/jdtls.tar.gz -C /opt/jdtls \ - && rm /tmp/jdtls.tar.gz \ - && printf '#!/bin/bash\njava -Declipse.application=org.eclipse.jdt.ls.core.id1 -Dosgi.bundles.defaultStartLevel=4 -Declipse.product=org.eclipse.jdt.ls.core.product -noverify -Xmx1G -jar /opt/jdtls/plugins/org.eclipse.equinox.launcher_*.jar -configuration /opt/jdtls/config_linux -data /tmp/workspace "$@"\n' > /usr/local/bin/jdtls \ - && chmod +x /usr/local/bin/jdtls - -# ============================================================================== -# END MAXIMAL ADDITIONS -# ============================================================================== - -# Set the working directory -WORKDIR /workspaces/serena - -# Copy all files for development -COPY . /workspaces/serena/ - -# Create Serena configuration -ENV SERENA_HOME=/workspaces/serena/config -RUN mkdir -p "$SERENA_HOME" \ - && cp src/serena/resources/serena_config.template.yml "$SERENA_HOME/serena_config.yml" \ - && sed -i 's/^gui_log_window: .*/gui_log_window: False/' "$SERENA_HOME/serena_config.yml" \ - && sed -i 's/^web_dashboard_listen_address: .*/web_dashboard_listen_address: 0.0.0.0/' "$SERENA_HOME/serena_config.yml" \ - && sed -i 's/^web_dashboard_open_on_launch: .*/web_dashboard_open_on_launch: False/' "$SERENA_HOME/serena_config.yml" - -CMD ["/bin/bash"] \ No newline at end of file From 4c7fa0e5789f97c086ee4aa2c5c5cb9770bab19f Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Mon, 29 Jun 2026 10:40:13 +0200 Subject: [PATCH 051/170] CI: caching of deps in the niche batch --- .github/workflows/pytest.yml | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6da4aef0..18fd67ac 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -602,12 +602,24 @@ jobs: with: r-version: '4.4.2' use-public-rspm: true + - name: Cache R packages (languageserver + its deps) + if: matrix.batch == 'niche' + id: cache-r + uses: actions/cache@v3 + with: + # setup-r exports R_LIBS_USER as $RUNNER_TEMP/Library (a stable path) and re-exports it on + # every run -- including cache hits -- so R finds the restored packages with no extra env. + # Bump the trailing -vN (or r-version) to invalidate after an R/OS-image upgrade. + path: ${{ env.R_LIBS_USER }} + key: r-pkgs-${{ runner.os }}-R4.4.2-languageserver-v1 - name: Install R sysdeps (libuv for fs package) + # Always run (NOT gated on the cache): the cached, compiled `fs` package links libuv at + # runtime, so the shared lib must be present even when the build below is skipped. if: matrix.batch == 'niche' && runner.os == 'Linux' shell: bash run: sudo apt-get update && sudo apt-get install -y libuv1-dev - name: Install R language server - if: matrix.batch == 'niche' + if: matrix.batch == 'niche' && steps.cache-r.outputs.cache-hit != 'true' shell: bash run: Rscript -e "install.packages('languageserver', repos='https://cloud.r-project.org')" - name: Set up Julia @@ -615,21 +627,40 @@ jobs: uses: julia-actions/setup-julia@v2 with: version: '1.12.6' + - name: Cache Julia depot (packages + precompiled) + # Caches the depot (~/.julia: packages, artifacts, compiled, ...) with an auto-managed key, + # so the LanguageServer Pkg.add below is a near-no-op (already installed + precompiled) on + # warm runs. The action handles save/restore itself -- no cache-hit gating needed. + if: matrix.batch == 'niche' + uses: julia-actions/cache@v2 - name: Install Julia LanguageServer if: matrix.batch == 'niche' shell: bash run: julia -e 'using Pkg; Pkg.add(Pkg.PackageSpec(name="LanguageServer", version="5.0.0"))' + - name: Cache Perl::LanguageServer (~/perl5) + if: matrix.batch == 'niche' && runner.os != 'Windows' + id: cache-perl + uses: actions/cache@v3 + with: + path: ~/perl5 + key: perl-ls-${{ runner.os }}-v1 - name: Install Perl::LanguageServer if: matrix.batch == 'niche' && runner.os != 'Windows' shell: bash run: | + # System deps are cheap and needed at runtime (the cached LS links libanyevent/libio-aio), + # so install them always; the slow cpanm build (XS compile of Coro etc.) runs only on a miss. if [[ "${{ runner.os }}" == "Linux" ]]; then sudo apt-get update sudo apt-get install -y cpanminus build-essential libanyevent-perl libio-aio-perl elif [[ "${{ runner.os }}" == "macOS" ]]; then brew install cpanminus fi - PERL_MM_USE_DEFAULT=1 cpanm --notest --force Perl::LanguageServer + if [[ "${{ steps.cache-perl.outputs.cache-hit }}" != "true" ]]; then + # --local-lib pins the install to ~/perl5 (matching the PERL5LIB exports below), so the + # cached directory is exactly what `perl -MPerl::LanguageServer` loads on a restore. + PERL_MM_USE_DEFAULT=1 cpanm --notest --force --local-lib="$HOME/perl5" Perl::LanguageServer + fi echo "PERL5LIB=$HOME/perl5/lib/perl5${PERL5LIB:+:${PERL5LIB}}" >> $GITHUB_ENV echo "PERL_LOCAL_LIB_ROOT=$HOME/perl5${PERL_LOCAL_LIB_ROOT:+:${PERL_LOCAL_LIB_ROOT}}" >> $GITHUB_ENV echo "PERL_MB_OPT=--install_base \"$HOME/perl5\"" >> $GITHUB_ENV From c89b0d1942df9b331efdfebd5d4b1dc531aa2461 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Mon, 29 Jun 2026 11:25:39 +0200 Subject: [PATCH 052/170] CI: mark flaky zls test as xfail --- test/solidlsp/zig/test_zig_basic.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/solidlsp/zig/test_zig_basic.py b/test/solidlsp/zig/test_zig_basic.py index e7413482..7108774e 100644 --- a/test/solidlsp/zig/test_zig_basic.py +++ b/test/solidlsp/zig/test_zig_basic.py @@ -145,6 +145,11 @@ class TestZigLanguageServer: @pytest.mark.skipif( sys.platform == "win32", reason="ZLS cross-file references don't work reliably on Windows - URI path handling issues" ) + @pytest.mark.xfail( + sys.platform == "darwin", + reason="ZLS cross-file references are flaky on macOS CI: sometimes finds 0 Calculator references in main.zig", + strict=False, + ) def test_cross_file_references_with_open_files(self, language_server: SolidLanguageServer) -> None: """ Test finding cross-file references with files open. From 0d8d5add9d15e48ce33aa2618f7e428a4ffe6ec5 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 29 Jun 2026 12:20:36 +0200 Subject: [PATCH 053/170] Add instructions on submitting PRs --- .github/pull_request_template.md | 4 ++++ .serena/memories/creating_pull_requests.md | 11 +++++++++++ .serena/project.yml | 3 +++ CONTRIBUTING.md | 6 ++++++ 4 files changed, 24 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .serena/memories/creating_pull_requests.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..c5fb5e14 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,4 @@ +## Checklist + +- [ ] This PR follows the guidelines in `CONTRIBUTING.md` regarding the scope of PRs. +- [ ] For changes that add features or fix problems, I have added an entry to `CHANGELOG.md`, which concisely describes the change. diff --git a/.serena/memories/creating_pull_requests.md b/.serena/memories/creating_pull_requests.md new file mode 100644 index 00000000..2f3911c1 --- /dev/null +++ b/.serena/memories/creating_pull_requests.md @@ -0,0 +1,11 @@ +# Creating Pull Requests + +* Make sure the instructions in `CONTRIBUTING.md` regarding the scope of PRs + are followed. +* For changes that add features or fix problems, + add an entry to `CHANGELOG.md` which *concisely* describes the change. + Note that details/background information on the change should be in the commit + message, not in the changelog. + Make sure to follow the existing format and to add your change to the + appropriate section. + diff --git a/.serena/project.yml b/.serena/project.yml index 8a180f59..e6db2f97 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -73,6 +73,9 @@ initial_prompt: | Comments: When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase clearly defines *what* it is. Any details then follow in subsequent sentences. + + Pull requests: + Read `mem:creating_pull_requests` when asked to participate in the creation of a pull request. # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77aa7c0e..f607233c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,12 @@ Every PR should cover a single logical change or a set of closely related change See the corresponding [memory](.serena/memories/adding_new_language_support_guide.md). +## Submitting Pull Requests + +Before submitting a PR, be sure to document your relevant changes (i.e. new features, fixes) in `CHANGELOG.md`. +Use a concise style and add your change to the appropriate section +("Language Servers", "Tools", "JetBrains", "CLI", "Memories", "Dashboard", "Hooks", "General"). + ## Python Environment Setup You can install a virtual environment with the required as follows From 7333a0c868e0527b4c763332b7916049f85c48de Mon Sep 17 00:00:00 2001 From: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:26:07 +0200 Subject: [PATCH 054/170] Replace mypy by ty (#1619) --- .serena/memories/conventions.md | 8 +-- .../memories/serena_repository_structure.md | 2 +- .serena/memories/suggested_commands.md | 2 +- .serena/memories/task_completion.md | 2 +- .serena/memories/tech_stack.md | 2 +- pyproject.toml | 70 ++++++++++++------- src/serena/agent.py | 2 +- src/serena/dashboard.py | 4 +- src/serena/gui_log_viewer.py | 5 +- .../jetbrains/jetbrains_plugin_client.py | 6 +- src/serena/mcp.py | 6 +- src/serena/project_server.py | 4 +- src/serena/symbol.py | 2 +- src/serena/task_executor.py | 2 +- src/serena/tools/cmd_tools.py | 2 +- src/serena/tools/query_project_tools.py | 2 +- src/serena/tools/symbol_tools.py | 6 +- src/serena/tools/tools_base.py | 4 +- src/serena/util/exception.py | 2 +- src/serena/util/logging.py | 2 +- src/serena/util/shell.py | 2 +- .../language_servers/al_language_server.py | 2 +- .../clangd_language_server.py | 8 +-- src/solidlsp/language_servers/clojure_lsp.py | 2 +- src/solidlsp/language_servers/common.py | 4 +- .../csharp_language_server.py | 2 +- .../language_servers/eclipse_jdtls.py | 14 ++-- .../elixir_tools/elixir_tools.py | 1 + .../fortran_language_server.py | 4 +- .../language_servers/godot_language_server.py | 2 +- src/solidlsp/language_servers/julia_server.py | 4 +- src/solidlsp/language_servers/marksman.py | 2 +- .../powershell_language_server.py | 4 +- .../language_servers/pyright_server.py | 7 +- src/solidlsp/language_servers/regal_server.py | 14 ++-- src/solidlsp/language_servers/solargraph.py | 8 +-- .../solidity_language_server.py | 14 ++-- .../svelte_language_server.py | 2 +- .../typescript_language_server.py | 2 +- .../language_servers/vue_language_server.py | 6 +- src/solidlsp/ls.py | 54 +++++++------- src/solidlsp/ls_config.py | 4 +- src/solidlsp/ls_process.py | 34 +++++---- src/solidlsp/ls_utils.py | 2 +- src/solidlsp/util/subprocess_util.py | 2 +- src/solidlsp/util/zip.py | 2 +- test/serena/test_hooks.py | 8 +-- test/serena/test_mcp.py | 2 +- test/serena/test_serena_agent.py | 4 +- test/serena/test_symbol.py | 8 +-- .../angular/test_angular_error_cases.py | 2 +- .../scala/test_scala_language_server.py | 1 - uv.lock | 69 +++++++----------- 53 files changed, 221 insertions(+), 210 deletions(-) diff --git a/.serena/memories/conventions.md b/.serena/memories/conventions.md index 5c18dc52..f224f005 100644 --- a/.serena/memories/conventions.md +++ b/.serena/memories/conventions.md @@ -13,11 +13,9 @@ - `ruff format` runs on `src scripts test`; same set for `ruff check`. - mccabe complexity cap: 20. -## Typing (mypy strict) -- `disallow_untyped_defs = true`, `disallow_incomplete_defs = true`, `strict_equality`, `strict_optional`, `warn_unreachable`, `no_implicit_optional`. -- Excluded paths: `^build/`, `^docs/`, `^test/resources/`. -- Test files relax `disallow_untyped_defs` (still type-checked otherwise). - +## Typing (ty) +- Type checker is **ty** (Astral), configured under `[tool.ty]` in `pyproject.toml`. + ## Tests - Language-server tests are pytest-marker-gated (one marker per language; see `pyproject.toml` `[tool.pytest.ini_options].markers`). Default `poe test` runs unmarked tests + whatever `PYTEST_MARKERS` selects. - Snapshot tests use **syrupy** with custom `--snapshot-patch-pycharm-diff` plugin (auto-added via `addopts`). diff --git a/.serena/memories/serena_repository_structure.md b/.serena/memories/serena_repository_structure.md index 69f173de..5fc70a97 100644 --- a/.serena/memories/serena_repository_structure.md +++ b/.serena/memories/serena_repository_structure.md @@ -104,7 +104,7 @@ The project uses modern Python tooling: - **uv** for fast dependency resolution and virtual environments - **pytest** for testing with language-specific markers (`@pytest.mark.csharp`) - **ruff** for linting and formatting -- **mypy** for type checking +- **ty** (Astral) for type checking ## Build and Development diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md index 5ae6638f..e4e44816 100644 --- a/.serena/memories/suggested_commands.md +++ b/.serena/memories/suggested_commands.md @@ -6,7 +6,7 @@ Run via `uv run poe <task>` (or `poe <task>` inside the activated venv). Poe exe - `poe test` — pytest on `test/` (per-language tests are marker-gated; pass `-m <marker>` to enable). - `poe lint` — ruff format-check + ruff check (no fixes). - `poe format` — ruff `--fix` then `ruff format` (mutates files). -- `poe type-check` — mypy on `src/serena`, `src/solidlsp`, and `test/` (test pass disables `no-untyped-def`). +- `poe type-check` — ty on `src/serena`, `src/solidlsp`, and `test/` (test pass relaxes pytest/mock-noisy rules via `[[tool.ty.overrides]]`). - Single test file: `uv run pytest test/path/to/test_x.py -vv`. Language-gated: add `-m python` etc. ## Docs diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md index 5ca33012..8d6948a9 100644 --- a/.serena/memories/task_completion.md +++ b/.serena/memories/task_completion.md @@ -3,7 +3,7 @@ After any code change in `src/` or `test/`, run: 1. `uv run poe format` — applies ruff fixes + formatting (mutates files). -2. `uv run poe type-check` — mypy strict on `src/serena`, `src/solidlsp`, `test/`. +2. `uv run poe type-check` — ty on `src/serena`, `src/solidlsp`, `test/`. 3. `uv run poe test` — pytest on affected files or affected languages, using `-m` markers. If prompt templates changed: `uv run python scripts/gen_prompt_factory.py` (regenerates `src/serena/generated/generated_prompt_factory.py`; use `uv run poe format` and commit the result). diff --git a/.serena/memories/tech_stack.md b/.serena/memories/tech_stack.md index f868cca6..ca8a3a6e 100644 --- a/.serena/memories/tech_stack.md +++ b/.serena/memories/tech_stack.md @@ -5,6 +5,6 @@ - Build backend: `hatchling`. Packages: `src/serena`, `src/interprompt`, `src/solidlsp`. - Task runner: **poethepoet** (`poe <task>`). Poe executor is `simple` (does NOT shell out via uv) — avoids env recreation while MCP server is running. - Key runtime deps: `mcp`, `flask` (dashboard), `pydantic`, `pygls` + `lsprotocol` (LSP), `anthropic`, `jinja2`, `ruamel.yaml`, `pywebview`/`pystray` (GUI), `pythonnet` (Windows only). -- Dev deps: `ruff` (lint+format), `mypy` (strict), `pytest` (+ `pytest-xdist`, `pytest-timeout`, `syrupy` snapshots), `sphinx`/`jupyter-book` for docs. +- Dev deps: `ruff` (lint+format), `ty` (Astral type checker; replaced mypy), `pytest` (+ `pytest-xdist`, `pytest-timeout`, `syrupy` snapshots), `sphinx`/`jupyter-book` for docs. - Optional extras: `agno` (Agno agent integration), `google` (gemini). - LSP client core lives under `src/solidlsp/`; one subdir per supported language server under `language_servers/`. diff --git a/pyproject.toml b/pyproject.toml index 9a1aafbe..232a4dea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ text = "MIT" dev = [ "jinja2==3.1.6", # In version 1.0.4 we get a NoneType error related to some config conversion (yml_analytics is None and should be a list) - "mypy==1.17.0", + "ty==0.0.24", "poethepoet==0.36.0", "pytest==8.4.1", "pytest-xdist==3.8.0", @@ -109,29 +109,47 @@ packages = ["src/serena", "src/interprompt", "src/solidlsp"] [tool.doc8] max-line-length = 1000 -[tool.mypy] -allow_redefinition = true -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_untyped_defs = true -ignore_missing_imports = true -no_implicit_optional = true -pretty = true -show_error_codes = true -show_error_context = true -show_traceback = true -strict_equality = true -strict_optional = true -warn_no_return = true -warn_redundant_casts = true -warn_unreachable = true -warn_unused_configs = true -warn_unused_ignores = false -exclude = "^build/|^docs/|^test/resources/" +[tool.ty.environment] +python-version = "3.11" +# Analyze for all platforms rather than defaulting to the OS ty happens to run on. This keeps the +# check deterministic across the CI matrix (Linux/Windows/macOS) and lets platform-conditional stdlib +# members (e.g. subprocess.CREATE_NO_WINDOW, ctypes.windll, pwd) resolve without per-OS type-ignores. +python-platform = "all" -[[tool.mypy.overrides]] -module = "test.*" -disallow_untyped_defs = false # untyped defs are OK in tests +[tool.ty.rules] +# Mirror mypy's ignore_missing_imports=true: optional extras (e.g. agno, google-genai) and +# platform-specific modules (e.g. AppKit on macOS) are not installed in the default dev environment, +# so we do not want unresolvable imports to fail the type check. +unresolved-import = "ignore" +possibly-missing-submodule = "ignore" + +[tool.ty.src] +# NOTE: test/resources is intentionally NOT excluded here. This config is also discovered by the ty +# language server (used by the PYTHON_TY diagnostics provider) when it analyzes the sample fixture +# repos under test/resources; excluding them here would suppress the diagnostics those tests rely on. +# test/resources is instead excluded from the `ty check test` CLI task via its --exclude flag. +exclude = ["build/", "docs/"] + +[[tool.ty.overrides]] +# Test code is heavily dynamic (pytest fixtures, MagicMock, intentionally loose Optionals). ty models +# pytest's fail/skip helpers and MagicMock far more strictly than mypy did (mypy inferred `Any` for +# these), which produces hundreds of diagnostics that are not real bugs. We therefore relax these +# specific type rules for tests only, while still catching genuinely broken test code (undefined +# names, syntax errors, unused type-ignore comments, etc.). The sample fixture repos under +# test/resources are excluded so the ty language server still reports real diagnostics on them. +include = ["test/**"] +exclude = ["test/resources/"] + +[tool.ty.overrides.rules] +invalid-argument-type = "ignore" +invalid-key = "ignore" +invalid-return-type = "ignore" +no-matching-overload = "ignore" +not-subscriptable = "ignore" +parameter-already-assigned = "ignore" +too-many-positional-arguments = "ignore" +unresolved-attribute = "ignore" +unsupported-operator = "ignore" [tool.poe.env] PYDEVD_DISABLE_FILE_VALIDATION = "1" @@ -152,9 +170,9 @@ _ruff_fix = "ruff check --fix src scripts test" _ruff_format = "ruff format src scripts test" lint = ["_ruff_format_check", "_ruff_check"] format = ["_ruff_fix", "_ruff_format"] -_mypy_core = "mypy src/serena src/solidlsp" -_mypy_test = "mypy --disable-error-code no-untyped-def test" -type-check = ["_mypy_core", "_mypy_test"] +_ty_core = "ty check src/serena src/solidlsp" +_ty_test = "ty check test --exclude test/resources" +type-check = ["_ty_core", "_ty_test"] # docs _autogen_docs = "python docs/autogen_docs.py" _sphinx_build = "sphinx-build -b html docs docs/_build -W --keep-going" diff --git a/src/serena/agent.py b/src/serena/agent.py index b5b727aa..d5b69269 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -1363,7 +1363,7 @@ class SerenaAgent: self.issue_task(lambda: self.get_active_project_or_raise().remove_language(language), name=f"RemoveLanguage:{language.value}") def get_tool(self, tool_class: type[TTool]) -> TTool: - return self._all_tools[tool_class] # type: ignore + return self._all_tools[tool_class] def print_tool_overview(self) -> None: ToolRegistry().print_tool_overview(self._active_tools.tools) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index f424ca04..b5a85495 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -789,7 +789,9 @@ class SerenaDashboardAPI: # patch flask.cli.show_server to avoid printing the server info from flask import cli - cli.show_server_banner = lambda *args, **kwargs: None + # ty cannot model reassigning a third-party module's function attribute (it rejects any + # replacement, even one with an identical signature), so the monkeypatch is suppressed here + cli.show_server_banner = lambda *args, **kwargs: None # ty: ignore[invalid-assignment] self._app.run(host=self._host, port=port, debug=False, use_reloader=False, threaded=True) return port diff --git a/src/serena/gui_log_viewer.py b/src/serena/gui_log_viewer.py index 57d3ac08..ccf42cd4 100644 --- a/src/serena/gui_log_viewer.py +++ b/src/serena/gui_log_viewer.py @@ -1,4 +1,3 @@ -# mypy: ignore-errors import logging import queue import sys @@ -109,7 +108,7 @@ class GuiLogViewer: if self.menubar is not None: dashboard_menu = tk.Menu(self.menubar, tearoff=0) - dashboard_menu.add_command(label="Copy URL", command=copy_url) # type: ignore + dashboard_menu.add_command(label="Copy URL", command=copy_url) self.menubar.add_cascade(label="Dashboard", menu=dashboard_menu) def add_log(self, message): @@ -303,7 +302,7 @@ class GuiLogViewer: if self.mode == "dashboard": self.menubar = tk.Menu(self.root) server_menu = tk.Menu(self.menubar, tearoff=0) - server_menu.add_command(label="Shutdown", command=self._shutdown_server) # type: ignore + server_menu.add_command(label="Shutdown", command=self._shutdown_server) self.menubar.add_cascade(label="Server", menu=server_menu) self.root.config(menu=self.menubar) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 3cbd915f..b03dacfe 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -225,7 +225,7 @@ class JetBrainsPluginClient(ToStringMixin): return ["_port", "project_root", "_plugin_version"] @classmethod - def from_project(cls, project: Project, log_warning: bool = True) -> Self: + def from_project(cls, project: Project, log_warning: bool = True) -> "JetBrainsPluginClient": resolved_path = Path(project.project_root).resolve() if cls._last_port is not None: @@ -355,7 +355,7 @@ class JetBrainsPluginClient(ToStringMixin): """ to_snake_case = lambda s: "".join(["_" + c.lower() if c.isupper() else c for c in s]) - def convert(x): # type: ignore + def convert(x): if isinstance(x, dict): return {to_snake_case(k): convert(v) for k, v in x.items()} elif isinstance(x, list): @@ -721,5 +721,5 @@ class JetBrainsPluginClient(ToStringMixin): def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore + def __exit__(self, exc_type, exc_val, exc_tb): self.close() diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 033533c1..321c9b35 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -34,7 +34,7 @@ from serena.util.logging import MemoryLogHandler log = logging.getLogger(__name__) -def configure_logging(*args, **kwargs) -> None: # type: ignore +def configure_logging(*args, **kwargs) -> None: # We only do something here if logging has not yet been configured. # Normally, logging is configured in the MCP server startup script. if not logging.is_enabled(): @@ -95,7 +95,7 @@ class SerenaFastMCPTool(FastMCPTool): param_desc = f"{param_doc.description.strip().strip('.') + '.'}" properties["description"] = param_desc[0].upper() + param_desc[1:] - def execute_fn(**kwargs) -> str: # type: ignore + def execute_fn(**kwargs) -> str: try: return tool.apply_ex(log_call=True, catch_exceptions=False, **kwargs) except ToolCallError as e: @@ -182,7 +182,7 @@ class SerenaMCPFactory: """ s = deepcopy(schema) - def walk(node): # type: ignore + def walk(node): if not isinstance(node, dict): # lists get handled by parent calls return node diff --git a/src/serena/project_server.py b/src/serena/project_server.py index bd6c7a79..ceec43a9 100644 --- a/src/serena/project_server.py +++ b/src/serena/project_server.py @@ -107,7 +107,9 @@ class ProjectServer: from flask import cli # suppress the default Flask startup banner - cli.show_server_banner = lambda *args, **kwargs: None + # ty cannot model reassigning a third-party module's function attribute (it rejects any + # replacement, even one with an identical signature), so the monkeypatch is suppressed here + cli.show_server_banner = lambda *args, **kwargs: None # ty: ignore[invalid-assignment] self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) return port diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 8bae7d14..8eb00595 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -1321,7 +1321,7 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): """ # avoid side effects by working on a deep-copy symbols_copy = copy.deepcopy(symbols) - return self._group_by(symbols_copy, self._group_keys, self._group_children_keys, is_children=False) # type: ignore + return self._group_by(symbols_copy, self._group_keys, self._group_children_keys, is_children=False) class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.OutputDict]): diff --git a/src/serena/task_executor.py b/src/serena/task_executor.py index 1c72f068..6e560145 100644 --- a/src/serena/task_executor.py +++ b/src/serena/task_executor.py @@ -200,7 +200,7 @@ class TaskExecutor: self._task_executor_task_index += 1 else: task_prefix_name = "BackgroundTask" - task_name = f"{task_prefix_name}:{name or task.__name__}" + task_name = f"{task_prefix_name}:{name or getattr(task, '__name__', 'task')}" if logged: log.info(f"Scheduling {task_name}") task_obj = self.Task(function=task, name=task_name, logged=logged, timeout=timeout) diff --git a/src/serena/tools/cmd_tools.py b/src/serena/tools/cmd_tools.py index e548903e..52afa95f 100644 --- a/src/serena/tools/cmd_tools.py +++ b/src/serena/tools/cmd_tools.py @@ -48,5 +48,5 @@ class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit): ) result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr) - result = result.json() + result = result.model_dump_json() return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/query_project_tools.py b/src/serena/tools/query_project_tools.py index 7d845a22..2340b538 100644 --- a/src/serena/tools/query_project_tools.py +++ b/src/serena/tools/query_project_tools.py @@ -62,7 +62,7 @@ class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveP assert registered_project is not None, f"Project {project_name} is not registered and cannot be queried." project = registered_project.get_project_instance(self.agent.serena_config) with tool.agent.active_project_context(project): - return tool.apply(**json.loads(tool_params_json)) # type: ignore + return tool.apply(**json.loads(tool_params_json)) def _is_project_server_required(self, tool: Tool) -> bool: match self.agent.get_language_backend(): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 7c11fce4..d88b7e9a 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -228,7 +228,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): # In python 3.15 we could specify extra_items=True in the TypedDict definition, # https://peps.python.org/pep-0728/ # If we ever upgrade to 3.15, we can remove the type: ignore[typeddict-unknown-key] - s_dict["info"] = symbol_info # type: ignore[typeddict-unknown-key] + s_dict["info"] = symbol_info grouped_symbol_dicts = self.symbol_dict_grouper.group(symbol_dicts) result = self._to_json(grouped_symbol_dicts) @@ -306,12 +306,12 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): } ) - result = self.symbol_dict_grouper.group(reference_dicts) # type: ignore + result = self.symbol_dict_grouper.group(reference_dicts) # shortened result closures, from least to most aggressive shortening def make_refs_without_context() -> str: """References with name_path and reference line, without surrounding code lines""" - grouped = self.symbol_dict_grouper.group(copy.deepcopy(ref_summaries)) # type: ignore + grouped = self.symbol_dict_grouper.group(copy.deepcopy(ref_summaries)) return f"References without surrounding lines:\n{self._to_json(grouped)}" def make_per_file_counts() -> str: diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 304333f3..0f09296f 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -327,7 +327,7 @@ class Tool(Component): """ return {} - def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: """ Applies the tool with logging and exception handling, using the given keyword arguments. This method either returns a string result or raises a ToolCallError in case of an error during tool application @@ -468,7 +468,7 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore + def __exit__(self, exc_type, exc_val, exc_tb): pass def format_result( diff --git a/src/serena/util/exception.py b/src/serena/util/exception.py index f9cbb562..8ab2f007 100644 --- a/src/serena/util/exception.py +++ b/src/serena/util/exception.py @@ -19,7 +19,7 @@ def is_headless_environment() -> bool: return False # Check for DISPLAY variable (required for X11) - if not os.environ.get("DISPLAY"): # type: ignore + if not os.environ.get("DISPLAY"): return True # Check for SSH session diff --git a/src/serena/util/logging.py b/src/serena/util/logging.py index 0d4f0970..279f955d 100644 --- a/src/serena/util/logging.py +++ b/src/serena/util/logging.py @@ -145,7 +145,7 @@ class SuspendedLoggersContext: root_logger.handlers.clear() return self - def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + def __exit__(self, exc_type, exc_val, exc_tb) -> None: root_logger = lg.getLogger() root_logger.handlers = self.saved_root_handlers if self.saved_root_level is not None: diff --git a/src/serena/util/shell.py b/src/serena/util/shell.py index a8fad3b1..8de5a67c 100644 --- a/src/serena/util/shell.py +++ b/src/serena/util/shell.py @@ -47,7 +47,7 @@ def subprocess_check_output( ) -> str: output = subprocess.check_output( args, stdin=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout, env=os.environ.copy(), cwd=cwd, **subprocess_kwargs() - ).decode(encoding) # type: ignore + ).decode(encoding) if strip: output = output.strip() return output diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index 438b1d3d..f954dbc7 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -1038,7 +1038,7 @@ class ALLanguageServer(SolidLanguageServer): if original_name != normalized_name and self._AL_OBJECT_NAME_PATTERN.match(original_name): sel_range = symbol.get("selectionRange") if sel_range: - start = sel_range.get("start") # type: ignore + start = sel_range.get("start") if start and "line" in start and "character" in start: line = start["line"] char = start["character"] diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index d9830ff4..b5c5d40a 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -82,7 +82,7 @@ class ClangdLanguageServer(SolidLanguageServer): @override def _document_symbols_cache_fingerprint(self) -> Hashable: cache_format_version = 1 - cpp_settings: dict[str, Any] = self._custom_settings or {} + cpp_settings = self._custom_settings or {} return ( cache_format_version, cpp_settings.get("clangd_version"), @@ -179,7 +179,7 @@ class ClangdLanguageServer(SolidLanguageServer): return None # Get the target directory from ls_specific_settings, default to .serena - cpp_settings: dict[str, Any] = self._custom_settings or {} + cpp_settings = self._custom_settings or {} compile_commands_rel_dir = cpp_settings.get("compile_commands_dir", ".serena") compile_commands_dir = os.path.join(self.repository_root_path, compile_commands_rel_dir) os.makedirs(compile_commands_dir, exist_ok=True) @@ -411,9 +411,9 @@ class ClangdLanguageServer(SolidLanguageServer): text_document_sync = capabilities["textDocumentSync"] if isinstance(text_document_sync, int): - assert text_document_sync == 2 # type: ignore + assert text_document_sync == 2 else: - assert text_document_sync["change"] == 2 # type: ignore + assert text_document_sync["change"] == 2 assert "completionProvider" in capabilities completion_provider = capabilities["completionProvider"] diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index 0a1213c4..1835ce99 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -318,7 +318,7 @@ class ClojureLSP(SolidLanguageServer): if source_paths is not None: initialization_options["source-paths"] = source_paths - result = { # type: ignore + result = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index 3cf9dcbf..ddc3fddd 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -106,7 +106,7 @@ class RuntimeDependencyCollection: if not PlatformUtils.get_platform_id().is_windows(): import pwd - kwargs["user"] = pwd.getpwuid(os.getuid()).pw_name # type: ignore + kwargs["user"] = pwd.getpwuid(os.getuid()).pw_name command = subprocess_util.convert_shell_cmd(command) log.info("Running command %s in '%s'", f"'{command}'" if isinstance(command, str) else command, cwd) @@ -120,7 +120,7 @@ class RuntimeDependencyCollection: stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs, - ) # type: ignore + ) if completed_process.returncode != 0: log.warning("Command '%s' failed with return code %d", command, completed_process.returncode) log.warning("Command output:\n%s", completed_process.stdout) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 66049a7e..296acad0 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -277,7 +277,7 @@ class CSharpLanguageServer(SolidLanguageServer): if original_name != normalized_name: sel_range = symbol.get("selectionRange") if sel_range: - start = sel_range.get("start") # type: ignore + start = sel_range.get("start") if start and "line" in start and "character" in start: line = start["line"] char = start["character"] diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 2479f13e..4614d9fe 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -1299,26 +1299,26 @@ class EclipseJDTLS(SolidLanguageServer): ], } - initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] # type: ignore + initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] # IntelliCode bundle: only attached in default vscode-java VSIX mode. # In upstream-jdtls mode (jdtls_path set) we don't ship IntelliCode — agentic Serena workflows # don't use completion ranking, so the bundle would be inert dead weight. if self.runtime_dependency_paths.intellicode_jar_path is not None: - initialize_params["initializationOptions"]["bundles"] = [self.runtime_dependency_paths.intellicode_jar_path] # type: ignore + initialize_params["initializationOptions"]["bundles"] = [self.runtime_dependency_paths.intellicode_jar_path] else: - initialize_params["initializationOptions"]["bundles"] = [] # type: ignore + initialize_params["initializationOptions"]["bundles"] = [] - initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ # type: ignore + initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} ] - for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: # type: ignore + for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: assert "name" in runtime assert "path" in runtime assert os.path.exists(runtime["path"]), f"Runtime required for eclipse_jdtls at path {runtime['path']} does not exist" - gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] # type: ignore + gradle_settings = initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"] # In upstream-jdtls mode we don't ship a Gradle distribution — Buildship will use the project's # ./gradlew wrapper or a system-installed Gradle via its standard discovery rules. if self.runtime_dependency_paths.gradle_path is not None: @@ -1483,7 +1483,7 @@ class EclipseJDTLS(SolidLanguageServer): symbol["name"] = symbol["name"][: symbol["name"].index("(")] children = symbol.get("children") if children: - for child in children: # type: ignore + for child in children: fix_name(child) for root_symbol in result: diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 11e754a1..6005df89 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -212,6 +212,7 @@ class ElixirTools(SolidLanguageServer): ) self.server_ready = threading.Event() self.request_id = 0 + self._building_project = False # Set generous timeout for Expert which can be slow to initialize and respond self.set_request_timeout(180.0) diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 45d7071b..39498971 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -299,8 +299,8 @@ class FortranLanguageServer(SolidLanguageServer): with self.open_file(relative_file_path): self.server.notify.did_save_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: uri, } } diff --git a/src/solidlsp/language_servers/godot_language_server.py b/src/solidlsp/language_servers/godot_language_server.py index 3186a123..453f0f92 100644 --- a/src/solidlsp/language_servers/godot_language_server.py +++ b/src/solidlsp/language_servers/godot_language_server.py @@ -80,7 +80,7 @@ class GodotLanguageServer(SolidLanguageServer): process_launch_info: ProcessLaunchInfo, logging_fn: Callable[[str, str, StringDict | str], None] | None, ) -> LanguageServerInterface: - settings: dict = self._custom_settings or {} + settings = self._custom_settings or {} port = settings.get("port", DEFAULT_GODOT_LS_PORT) request_timeout = settings.get("request_timeout", DEFAULT_GODOT_REQUEST_TIMEOUT) self._configured_request_timeout = settings.get("request_timeout") diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index 71f978c9..7b7579b6 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -141,7 +141,7 @@ class JuliaLanguageServer(SolidLanguageServer): Returns the initialize params for the Julia Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params: InitializeParams = { # type: ignore + initialize_params: InitializeParams = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, @@ -171,7 +171,7 @@ class JuliaLanguageServer(SolidLanguageServer): } ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start the LanguageServer.jl server process.""" diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index 13c32147..72d7268a 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -201,7 +201,7 @@ class Marksman(SolidLanguageServer): Returns the initialize params for the Marksman Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params: InitializeParams = { # type: ignore + initialize_params: InitializeParams = { "processId": os.getpid(), "locale": "en", "rootPath": repository_absolute_path, diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 70a06d1a..1879ac02 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -412,8 +412,8 @@ class PowerShellLanguageServer(SolidLanguageServer): with self.open_file(relative_file_path): self.server.notify.did_save_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: uri, } } diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index d4aa1265..f369706c 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -65,7 +65,7 @@ class PyrightServer(SolidLanguageServer): Returns the initialize params for the Pyright Language Server. """ # Create basic initialization parameters - initialize_params = { # type: ignore + initialize_params = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": pathlib.Path(repository_absolute_path).as_uri(), @@ -168,9 +168,10 @@ class PyrightServer(SolidLanguageServer): message_text = "" percentage: object | None = None if isinstance(params, dict): - raw_message = params.get("message") + params_dict = cast("dict[str, object]", params) + raw_message = params_dict.get("message") message_text = "" if raw_message is None else str(raw_message) - percentage = params.get("percentage") + percentage = params_dict.get("percentage") elif params is not None: message_text = str(params) diff --git a/src/solidlsp/language_servers/regal_server.py b/src/solidlsp/language_servers/regal_server.py index 72eb19db..25870a04 100644 --- a/src/solidlsp/language_servers/regal_server.py +++ b/src/solidlsp/language_servers/regal_server.py @@ -3,6 +3,7 @@ import logging import os import shutil +from typing import cast from overrides import override @@ -62,7 +63,7 @@ class RegalLanguageServer(SolidLanguageServer): :return: LSP initialization parameters """ root_uri = PathUtils.path_to_uri(repository_absolute_path) - return { + initialize_params = { "processId": os.getpid(), "locale": "en", "rootPath": repository_absolute_path, @@ -76,9 +77,9 @@ class RegalLanguageServer(SolidLanguageServer): "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] + "symbolKind": {"valueSet": list(range(1, 27))}, }, - "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, # type: ignore[list-item] + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, "formatting": {"dynamicRegistration": True}, }, @@ -95,17 +96,18 @@ class RegalLanguageServer(SolidLanguageServer): } ], } + return cast(InitializeParams, initialize_params) def _start_server(self) -> None: """Start Regal language server process and wait for initialization.""" - def register_capability_handler(params) -> None: # type: ignore[no-untyped-def] + def register_capability_handler(params) -> None: return - def window_log_message(msg) -> None: # type: ignore[no-untyped-def] + def window_log_message(msg) -> None: log.info(f"LSP: window/logMessage: {msg}") - def do_nothing(params) -> None: # type: ignore[no-untyped-def] + def do_nothing(params) -> None: return self.server.on_request("client/registerCapability", register_capability_handler) diff --git a/src/solidlsp/language_servers/solargraph.py b/src/solidlsp/language_servers/solargraph.py index f42f0854..9379bc6d 100644 --- a/src/solidlsp/language_servers/solargraph.py +++ b/src/solidlsp/language_servers/solargraph.py @@ -133,7 +133,7 @@ class Solargraph(SolidLanguageServer): if bundle_cmd.startswith("bin/"): bundle_full_path = os.path.join(repository_root_path, bundle_cmd) else: - bundle_full_path = find_executable_with_extensions(bundle_cmd) # type: ignore[assignment] + bundle_full_path = find_executable_with_extensions(bundle_cmd) if bundle_full_path and os.path.exists(bundle_full_path): bundle_path = bundle_full_path if bundle_cmd.startswith("bin/") else bundle_cmd break @@ -275,12 +275,12 @@ class Solargraph(SolidLanguageServer): root_uri = pathlib.Path(repository_absolute_path).as_uri() exclude_patterns = Solargraph._get_ruby_exclude_patterns(repository_absolute_path) - initialize_params: InitializeParams = { # type: ignore + initialize_params: InitializeParams = { "processId": os.getpid(), "rootPath": repository_absolute_path, "rootUri": root_uri, "initializationOptions": { - "exclude": exclude_patterns, # type: ignore[dict-item] + "exclude": exclude_patterns, }, "capabilities": { "workspace": { @@ -301,7 +301,7 @@ class Solargraph(SolidLanguageServer): } ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """ diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index d0c34053..7ee35953 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -313,8 +313,8 @@ class SolidityLanguageServer(SolidLanguageServer): } self.server.notify.did_open_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: uri, LSPConstants.LANGUAGE_ID: self.language_id, LSPConstants.VERSION: 0, @@ -324,12 +324,12 @@ class SolidityLanguageServer(SolidLanguageServer): ) try: self.server.notify.did_change_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: uri, LSPConstants.VERSION: 1, }, - LSPConstants.CONTENT_CHANGES: [ # type: ignore + LSPConstants.CONTENT_CHANGES: [ { LSPConstants.RANGE: { "start": {"line": 0, "character": 0}, @@ -348,8 +348,8 @@ class SolidityLanguageServer(SolidLanguageServer): ) finally: self.server.notify.did_close_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: uri, } } diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index a0cff5b1..c04730d8 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -438,7 +438,7 @@ class SvelteLanguageServer(SolidLanguageServer): return _orig_notify_send("$/onDidChangeTsOrJsFile", {"uri": uri, "changes": changes}) - self.server.notify.send_notification = send_notification_wrapped # type: ignore[method-assign] # type: ignore[method-assign] # type: ignore[method-assign] + self.server.notify.send_notification = send_notification_wrapped def _get_initialize_params(self) -> InitializeParams: """ diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 731ad835..892843f5 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -36,7 +36,7 @@ if os.name != "nt": # Unix-like systems import pwd else: # Dummy pwd module for Windows - class pwd: # type: ignore + class pwd: @staticmethod def getpwuid(uid: Any) -> Any: return type("obj", (), {"pw_name": os.environ.get("USERNAME", "unknown")})() diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index 84d1ce8c..f5112c0f 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -344,7 +344,7 @@ class VueLanguageServer(SolidLanguageServer): continue new_item: dict = {} - new_item.update(item) # type: ignore[arg-type] + new_item.update(item) new_item["absolutePath"] = str(abs_path) new_item["relativePath"] = str(rel_path) result.append(ls_types.Location(**new_item)) # type: ignore @@ -390,7 +390,7 @@ class VueLanguageServer(SolidLanguageServer): log.debug(f"Skipping invalid location item: {item}") continue - abs_path = PathUtils.uri_to_path(item["uri"]) # type: ignore[arg-type] + abs_path = PathUtils.uri_to_path(item["uri"]) if not Path(abs_path).is_relative_to(self.repository_root_path): log.warning(f"Found file reference outside repository: {abs_path}, skipping") continue @@ -401,7 +401,7 @@ class VueLanguageServer(SolidLanguageServer): continue new_item: dict = {} - new_item.update(item) # type: ignore[arg-type] + new_item.update(item) new_item["absolutePath"] = str(abs_path) new_item["relativePath"] = str(rel_path) ret.append(Location(**new_item)) # type: ignore diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index c311c0db..558a9148 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -108,8 +108,8 @@ class LSPFileBuffer: return self._is_open_in_ls = True self.language_server.server.notify.did_open_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: self.uri, LSPConstants.LANGUAGE_ID: self.language_id, LSPConstants.VERSION: 0, @@ -121,8 +121,8 @@ class LSPFileBuffer: def close(self) -> None: if self._is_open_in_ls: self.language_server.server.notify.did_close_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: self.uri, } } @@ -223,10 +223,10 @@ class SymbolBodyFactory: return existing_body assert "location" in symbol - start_line = symbol["location"]["range"]["start"]["line"] # type: ignore - end_line = symbol["location"]["range"]["end"]["line"] # type: ignore - start_col = symbol["location"]["range"]["start"]["character"] # type: ignore - end_col = symbol["location"]["range"]["end"]["character"] # type: ignore + start_line = symbol["location"]["range"]["start"]["line"] + end_line = symbol["location"]["range"]["end"]["line"] + start_col = symbol["location"]["range"]["start"]["character"] + end_col = symbol["location"]["range"]["end"]["character"] return SymbolBody(self._lines, start_line, start_col, end_line, end_col) @@ -508,7 +508,7 @@ class SolidLanguageServer(ABC): # For now, we assume that all language server implementations have the same signature of the constructor # (which, unfortunately, differs from the signature of the base class). # If this assumption is ever violated, we need branching logic here. - ls = ls_class(config, repository_root_path, solidlsp_settings) # type: ignore + ls = ls_class(config, repository_root_path, solidlsp_settings) ls.set_request_timeout(timeout) return ls @@ -590,7 +590,7 @@ class SolidLanguageServer(ABC): log.debug(f"LSP: {source} -> {target}: {msg!s}") else: - logging_fn = None # type: ignore + logging_fn = None # create the LanguageServerHandler, which provides the functionality to start the language server and communicate with it, # preparing the launch command beforehand @@ -1024,8 +1024,8 @@ class SolidLanguageServer(ABC): if self._supports_pull_diagnostics(): try: response = self.server.send.text_document_diagnostic( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.URI: uri, } } @@ -1380,8 +1380,8 @@ class SolidLanguageServer(ABC): new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted) file_buffer.contents = new_contents self.server.notify.did_change_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.VERSION: file_buffer.version, LSPConstants.URI: file_buffer.uri, }, @@ -1423,8 +1423,8 @@ class SolidLanguageServer(ABC): ) file_buffer.contents = new_contents self.server.notify.did_change_text_document( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore + { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict + LSPConstants.TEXT_DOCUMENT: { LSPConstants.VERSION: file_buffer.version, LSPConstants.URI: file_buffer.uri, }, @@ -1766,15 +1766,15 @@ class SolidLanguageServer(ABC): response = self.server.send.completion(completion_params) if isinstance(response, list): response = {"items": response, "isIncomplete": False} - if response is None or not response["isIncomplete"]: # type: ignore + if response is None or not response["isIncomplete"]: break # TODO: Understand how to appropriately handle `isIncomplete` - if response is None or (response["isIncomplete"] and not allow_incomplete): # type: ignore + if response is None or (response["isIncomplete"] and not allow_incomplete): return [] if "items" in response: - response = response["items"] # type: ignore + response = response["items"] response = cast(list[LSPTypes.CompletionItem], response) @@ -1792,8 +1792,8 @@ class SolidLanguageServer(ABC): if "label" in item: completion_item["completionText"] = item["label"] - completion_item["kind"] = item["kind"] # type: ignore - elif "insertText" in item: # type: ignore + completion_item["kind"] = item["kind"] + elif "insertText" in item: completion_item["completionText"] = item["insertText"] completion_item["kind"] = item["kind"] elif "textEdit" in item and "newText" in item["textEdit"]: @@ -1977,9 +1977,9 @@ class SolidLanguageServer(ABC): item["location"] = tree_location location = item["location"] if "absolutePath" not in location: - location["absolutePath"] = absolute_path # type: ignore + location["absolutePath"] = absolute_path if "relativePath" not in location: - location["relativePath"] = relative_file_path # type: ignore + location["relativePath"] = relative_file_path item["body"] = self.create_symbol_body(item, factory=body_factory) @@ -2021,7 +2021,7 @@ class SolidLanguageServer(ABC): if "children" in usymbol: usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol) # type: ignore else: - usymbol["children"] = [] # type: ignore + usymbol["children"] = [] unified_symbols.append(usymbol) return unified_symbols @@ -2080,7 +2080,7 @@ class SolidLanguageServer(ABC): return [] # Create package symbol for directory - package_symbol = ls_types.UnifiedSymbolInformation( # type: ignore + package_symbol = ls_types.UnifiedSymbolInformation( name=os.path.basename(abs_dir_path), kind=ls_types.SymbolKind.Package, location=ls_types.Location( @@ -2128,7 +2128,7 @@ class SolidLanguageServer(ABC): # Create file symbol, link with children file_range = self._get_range_from_file_content(file_data.contents) - file_symbol = ls_types.UnifiedSymbolInformation( # type: ignore + file_symbol = ls_types.UnifiedSymbolInformation( name=os.path.splitext(contained_dir_or_file_name)[0], kind=ls_types.SymbolKind.File, range=file_range, @@ -2980,7 +2980,7 @@ class SolidLanguageServer(ABC): migrated_cache[new_cache_key] = (file_hash, root_symbols) num_symbols_migrated += len(all_symbols) log.info("Migrated %d document symbols from legacy cache", num_symbols_migrated) - self._raw_document_symbols_cache = migrated_cache # type: ignore + self._raw_document_symbols_cache = migrated_cache self._raw_document_symbols_cache_is_modified = True self._save_raw_document_symbols_cache() legacy_cache_file.unlink() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index b58f056c..6be2fc80 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -571,7 +571,7 @@ class Language(str, Enum): return CSharpLanguageServer case self.CSHARP_OMNISHARP: - from solidlsp.language_servers.omnisharp import OmniSharp # type: ignore[attr-defined] + from solidlsp.language_servers.omnisharp import OmniSharp return OmniSharp case self.TYPESCRIPT: @@ -679,7 +679,7 @@ class Language(str, Enum): return ZigLanguageServer case self.NIX: - from solidlsp.language_servers.nixd_ls import NixLanguageServer # type: ignore + from solidlsp.language_servers.nixd_ls import NixLanguageServer return NixLanguageServer case self.LUA: diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index 820947af..9fc84a8e 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -10,7 +10,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from queue import Empty, Queue -from typing import IO, Any, AnyStr +from typing import IO, Any, AnyStr, cast from sensai.util.string import ToStringMixin @@ -462,24 +462,30 @@ class StdioLanguageServer(LanguageServerInterface): log.info("Starting language server process via command: %s", self.process_launch_info.cmd) kwargs = subprocess_util.subprocess_kwargs() kwargs["start_new_session"] = self.start_independent_lsp_process - self.process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stdin=subprocess.PIPE, - stderr=subprocess.PIPE, - env=child_proc_env, - cwd=self.process_launch_info.cwd, - shell=True, - **kwargs, + # the language server is launched with binary (bytes) pipes; the cast is needed because the + # presence of platform-specific **kwargs prevents ty from selecting the bytes Popen overload + process = cast( + "subprocess.Popen[bytes]", + subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.PIPE, + env=child_proc_env, + cwd=self.process_launch_info.cwd, + shell=True, + **kwargs, + ), ) + self.process = process # Check if process terminated immediately - if self.process.returncode is not None: + if process.returncode is not None: log.error("Language server has already terminated/could not be started") # Process has already terminated - stderr_data = self.process.stderr.read() if self.process.stderr else b"" + stderr_data = process.stderr.read() if process.stderr else b"" error_message = stderr_data.decode("utf-8", errors="replace") - raise RuntimeError(f"Process terminated immediately with code {self.process.returncode}. Error: {error_message}") + raise RuntimeError(f"Process terminated immediately with code {process.returncode}. Error: {error_message}") # start threads to read stdout and stderr of the process threading.Thread( @@ -522,7 +528,7 @@ class StdioLanguageServer(LanguageServerInterface): except Exception: pass - def _read_bytes_from_process(self, process, stream, num_bytes) -> bytes: # type: ignore + def _read_bytes_from_process(self, process, stream, num_bytes) -> bytes: """Read exactly num_bytes from process stdout""" data = b"" while len(data) < num_bytes: diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index eb0456ac..0f47a3e7 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -527,7 +527,7 @@ class PlatformUtils: _anonymous_ = ("u",) sys_info = SYSTEM_INFO() - ctypes.windll.kernel32.GetNativeSystemInfo(ctypes.byref(sys_info)) # type: ignore + ctypes.windll.kernel32.GetNativeSystemInfo(ctypes.byref(sys_info)) arch_map = { 9: "AMD64", diff --git a/src/solidlsp/util/subprocess_util.py b/src/solidlsp/util/subprocess_util.py index 579b856e..bf994e45 100644 --- a/src/solidlsp/util/subprocess_util.py +++ b/src/solidlsp/util/subprocess_util.py @@ -15,7 +15,7 @@ def subprocess_kwargs() -> dict: """ kwargs = {} if platform.system() == "Windows": - kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW # type: ignore + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW return kwargs diff --git a/src/solidlsp/util/zip.py b/src/solidlsp/util/zip.py index bf014923..d93d9421 100644 --- a/src/solidlsp/util/zip.py +++ b/src/solidlsp/util/zip.py @@ -115,7 +115,7 @@ class SafeZipExtractor: """ if sys.platform.startswith("win"): return Path(rf"\\?\{os.path.abspath(path)}") - return path # type: ignore + return path # Example usage: diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py index e762c130..d7287678 100644 --- a/test/serena/test_hooks.py +++ b/test/serena/test_hooks.py @@ -464,22 +464,22 @@ class TestToolUseCounter: counter = ToolUseCounter(n_recent_grep_uses=2, n_recent_read_file_uses=1) hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path)})() - counter.save(hook_stub) # type: ignore[arg-type] - loaded = ToolUseCounter.load(hook_stub) # type: ignore[arg-type] + counter.save(hook_stub) + loaded = ToolUseCounter.load(hook_stub) assert loaded.n_recent_grep_uses == 2 assert loaded.n_recent_read_file_uses == 1 def test_load_returns_fresh_counter_on_missing_file(self, tmp_path: Path): hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path / "nonexistent")})() - loaded = ToolUseCounter.load(hook_stub) # type: ignore[arg-type] + loaded = ToolUseCounter.load(hook_stub) assert loaded == ToolUseCounter() def test_load_returns_fresh_counter_on_corrupt_file(self, tmp_path: Path): hook_stub = type("HookStub", (), {"session_persistence_dir": str(tmp_path)})() path = tmp_path / ToolUseCounter._FILE_NAME path.write_bytes(b"not a pickle") - loaded = ToolUseCounter.load(hook_stub) # type: ignore[arg-type] + loaded = ToolUseCounter.load(hook_stub) assert loaded == ToolUseCounter() def test_is_hook_active_respects_min_interval(self): diff --git a/test/serena/test_mcp.py b/test/serena/test_mcp.py index 58508ba6..731a1cc6 100644 --- a/test/serena/test_mcp.py +++ b/test/serena/test_mcp.py @@ -25,7 +25,7 @@ class BaseMockTool(Tool): """A mock Tool class for testing.""" def __init__(self): - super().__init__(MockAgent()) # type: ignore + super().__init__(MockAgent()) class BasicTool(BaseMockTool): diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 7625e50a..d93722ba 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -46,7 +46,7 @@ class BaseCase: id: str def to_pytest_param(self, *marks: MarkDecorator | Mark) -> ParameterSet: - return pytest.param(self.language, self, marks=[*get_pytest_markers(self.language), *marks], id=self.id) # type: ignore + return pytest.param(self.language, self, marks=[*get_pytest_markers(self.language), *marks], id=self.id) @dataclass @@ -1290,7 +1290,7 @@ class TestPromptProvision: @classmethod def _call_tool(cls, agent: SerenaAgent, tool_class: type[Tool], session_id: str = "global", **kwargs) -> str: - result = agent.get_tool(tool_class).apply_ex(mcp_ctx=cls.MockContext(session_id), **kwargs) # type: ignore + result = agent.get_tool(tool_class).apply_ex(mcp_ctx=cls.MockContext(session_id), **kwargs) return result @staticmethod diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index 08188f49..db315270 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -251,10 +251,10 @@ class TestSymbolDictTypes: :param key_type: the corresponding key type (Literal[...]) that the dict should have for keys """ dict_type_keys = dict_type.__annotations__.keys() - assert len(dict_type_keys) == len( - key_type.__args__ # type: ignore - ), f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" # type: ignore - for expected_key in key_type.__args__: # type: ignore + assert len(dict_type_keys) == len(key_type.__args__), ( + f"Expected {len(key_type.__args__)} keys in {dict_type}, but got {len(dict_type_keys)}" + ) + for expected_key in key_type.__args__: assert expected_key in dict_type_keys, f"Expected key '{expected_key}' not found in {dict_type}" def test_ls_symbol_dict_type(self): diff --git a/test/solidlsp/angular/test_angular_error_cases.py b/test/solidlsp/angular/test_angular_error_cases.py index 52b51592..f65e7d09 100644 --- a/test/solidlsp/angular/test_angular_error_cases.py +++ b/test/solidlsp/angular/test_angular_error_cases.py @@ -265,7 +265,7 @@ class TestAngularCoreProbe: from solidlsp.language_servers.angular_language_server import AngularLanguageServer stub = type("Stub", (), {"repository_root_path": repo_path})() - return AngularLanguageServer._find_angular_core_install(stub) # type: ignore[arg-type] + return AngularLanguageServer._find_angular_core_install(stub) def test_finds_core_at_project_root(self, tmp_path) -> None: core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json" diff --git a/test/solidlsp/scala/test_scala_language_server.py b/test/solidlsp/scala/test_scala_language_server.py index 930010fb..aa94eb32 100644 --- a/test/solidlsp/scala/test_scala_language_server.py +++ b/test/solidlsp/scala/test_scala_language_server.py @@ -1,4 +1,3 @@ -# type: ignore import os import pytest diff --git a/uv.lock b/uv.lock index 27c9ae97..3595e84a 100644 --- a/uv.lock +++ b/uv.lock @@ -1485,47 +1485,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/f2/66bd65ca0139675a0d7b18f0bada6e12b51a984e41a76dbe44761bf1b3ee/mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4", size = 7820, upload-time = "2024-10-16T13:16:17.566Z" }, ] -[[package]] -name = "mypy" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1e/e3/034322d5a779685218ed69286c32faa505247f1f096251ef66c8fd203b08/mypy-1.17.0.tar.gz", hash = "sha256:e5d7ccc08ba089c06e2f5629c660388ef1fee708444f1dee0b9203fa031dee03", size = 3352114, upload-time = "2025-07-14T20:34:30.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/82efb502b0b0f661c49aa21cfe3e1999ddf64bf5500fc03b5a1536a39d39/mypy-1.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d4fe5c72fd262d9c2c91c1117d16aac555e05f5beb2bae6a755274c6eec42be", size = 10914150, upload-time = "2025-07-14T20:31:51.985Z" }, - { url = "https://files.pythonhosted.org/packages/03/96/8ef9a6ff8cedadff4400e2254689ca1dc4b420b92c55255b44573de10c54/mypy-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d96b196e5c16f41b4f7736840e8455958e832871990c7ba26bf58175e357ed61", size = 10039845, upload-time = "2025-07-14T20:32:30.527Z" }, - { url = "https://files.pythonhosted.org/packages/df/32/7ce359a56be779d38021d07941cfbb099b41411d72d827230a36203dbb81/mypy-1.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73a0ff2dd10337ceb521c080d4147755ee302dcde6e1a913babd59473904615f", size = 11837246, upload-time = "2025-07-14T20:32:01.28Z" }, - { url = "https://files.pythonhosted.org/packages/82/16/b775047054de4d8dbd668df9137707e54b07fe18c7923839cd1e524bf756/mypy-1.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cfcc1179c4447854e9e406d3af0f77736d631ec87d31c6281ecd5025df625d", size = 12571106, upload-time = "2025-07-14T20:34:26.942Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cf/fa33eaf29a606102c8d9ffa45a386a04c2203d9ad18bf4eef3e20c43ebc8/mypy-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56f180ff6430e6373db7a1d569317675b0a451caf5fef6ce4ab365f5f2f6c3", size = 12759960, upload-time = "2025-07-14T20:33:42.882Z" }, - { url = "https://files.pythonhosted.org/packages/94/75/3f5a29209f27e739ca57e6350bc6b783a38c7621bdf9cac3ab8a08665801/mypy-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:eafaf8b9252734400f9b77df98b4eee3d2eecab16104680d51341c75702cad70", size = 9503888, upload-time = "2025-07-14T20:32:34.392Z" }, - { url = "https://files.pythonhosted.org/packages/12/e9/e6824ed620bbf51d3bf4d6cbbe4953e83eaf31a448d1b3cfb3620ccb641c/mypy-1.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f986f1cab8dbec39ba6e0eaa42d4d3ac6686516a5d3dccd64be095db05ebc6bb", size = 11086395, upload-time = "2025-07-14T20:34:11.452Z" }, - { url = "https://files.pythonhosted.org/packages/ba/51/a4afd1ae279707953be175d303f04a5a7bd7e28dc62463ad29c1c857927e/mypy-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:51e455a54d199dd6e931cd7ea987d061c2afbaf0960f7f66deef47c90d1b304d", size = 10120052, upload-time = "2025-07-14T20:33:09.897Z" }, - { url = "https://files.pythonhosted.org/packages/8a/71/19adfeac926ba8205f1d1466d0d360d07b46486bf64360c54cb5a2bd86a8/mypy-1.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3204d773bab5ff4ebbd1f8efa11b498027cd57017c003ae970f310e5b96be8d8", size = 11861806, upload-time = "2025-07-14T20:32:16.028Z" }, - { url = "https://files.pythonhosted.org/packages/0b/64/d6120eca3835baf7179e6797a0b61d6c47e0bc2324b1f6819d8428d5b9ba/mypy-1.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1051df7ec0886fa246a530ae917c473491e9a0ba6938cfd0ec2abc1076495c3e", size = 12744371, upload-time = "2025-07-14T20:33:33.503Z" }, - { url = "https://files.pythonhosted.org/packages/1f/dc/56f53b5255a166f5bd0f137eed960e5065f2744509dfe69474ff0ba772a5/mypy-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f773c6d14dcc108a5b141b4456b0871df638eb411a89cd1c0c001fc4a9d08fc8", size = 12914558, upload-time = "2025-07-14T20:33:56.961Z" }, - { url = "https://files.pythonhosted.org/packages/69/ac/070bad311171badc9add2910e7f89271695a25c136de24bbafc7eded56d5/mypy-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:1619a485fd0e9c959b943c7b519ed26b712de3002d7de43154a489a2d0fd817d", size = 9585447, upload-time = "2025-07-14T20:32:20.594Z" }, - { url = "https://files.pythonhosted.org/packages/be/7b/5f8ab461369b9e62157072156935cec9d272196556bdc7c2ff5f4c7c0f9b/mypy-1.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c41aa59211e49d717d92b3bb1238c06d387c9325d3122085113c79118bebb06", size = 11070019, upload-time = "2025-07-14T20:32:07.99Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f8/c49c9e5a2ac0badcc54beb24e774d2499748302c9568f7f09e8730e953fa/mypy-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e69db1fb65b3114f98c753e3930a00514f5b68794ba80590eb02090d54a5d4a", size = 10114457, upload-time = "2025-07-14T20:33:47.285Z" }, - { url = "https://files.pythonhosted.org/packages/89/0c/fb3f9c939ad9beed3e328008b3fb90b20fda2cddc0f7e4c20dbefefc3b33/mypy-1.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:03ba330b76710f83d6ac500053f7727270b6b8553b0423348ffb3af6f2f7b889", size = 11857838, upload-time = "2025-07-14T20:33:14.462Z" }, - { url = "https://files.pythonhosted.org/packages/4c/66/85607ab5137d65e4f54d9797b77d5a038ef34f714929cf8ad30b03f628df/mypy-1.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:037bc0f0b124ce46bfde955c647f3e395c6174476a968c0f22c95a8d2f589bba", size = 12731358, upload-time = "2025-07-14T20:32:25.579Z" }, - { url = "https://files.pythonhosted.org/packages/73/d0/341dbbfb35ce53d01f8f2969facbb66486cee9804048bf6c01b048127501/mypy-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c38876106cb6132259683632b287238858bd58de267d80defb6f418e9ee50658", size = 12917480, upload-time = "2025-07-14T20:34:21.868Z" }, - { url = "https://files.pythonhosted.org/packages/64/63/70c8b7dbfc520089ac48d01367a97e8acd734f65bd07813081f508a8c94c/mypy-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:d30ba01c0f151998f367506fab31c2ac4527e6a7b2690107c7a7f9e3cb419a9c", size = 9589666, upload-time = "2025-07-14T20:34:16.841Z" }, - { url = "https://files.pythonhosted.org/packages/e3/fc/ee058cc4316f219078464555873e99d170bde1d9569abd833300dbeb484a/mypy-1.17.0-py3-none-any.whl", hash = "sha256:15d9d0018237ab058e5de3d8fce61b6fa72cc59cc78fd91f1b474bce12abf496", size = 2283195, upload-time = "2025-07-14T20:31:54.753Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "myst-nb" version = "1.4.0" @@ -2890,7 +2849,6 @@ agno = [ dev = [ { name = "jinja2" }, { name = "jupyter-book" }, - { name = "mypy" }, { name = "poethepoet" }, { name = "pyasn1" }, { name = "pyinstrument" }, @@ -2906,6 +2864,7 @@ dev = [ { name = "syrupy" }, { name = "toml-sort" }, { name = "tornado" }, + { name = "ty" }, { name = "types-pyyaml" }, { name = "types-requests" }, { name = "wheel" }, @@ -2931,7 +2890,6 @@ requires-dist = [ { name = "jupyter-book", marker = "extra == 'dev'", specifier = "==1.0.4.post1" }, { name = "lsprotocol", specifier = "==2025.0.0" }, { name = "mcp", specifier = "==1.27.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" }, { name = "oslex", specifier = "==2.0.0" }, { name = "overrides", specifier = "==7.7.0" }, { name = "pathspec", specifier = "==0.12.1" }, @@ -2967,6 +2925,7 @@ requires-dist = [ { name = "toml-sort", marker = "extra == 'dev'", specifier = "==0.24.2" }, { name = "tornado", marker = "extra == 'dev'", specifier = "==6.5.7" }, { name = "tqdm", specifier = "==4.67.1" }, + { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.24" }, { name = "types-pyyaml", specifier = "==6.0.12.20250516" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.20250516" }, { name = "types-requests", marker = "extra == 'dev'", specifier = "==2.32.4.20250809" }, @@ -3593,6 +3552,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "ty" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, + { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +] + [[package]] name = "typer" version = "0.24.1" From a6d052a6a275d7ee9d4b3484c96f9261de3419e1 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Mon, 29 Jun 2026 12:56:44 +0200 Subject: [PATCH 055/170] print-system-prompt: make project optional, drop logging + stray prints Render the system prompt without activating a project: `project` is now an optional positional (default None -> project-independent prompt). Drop the --log-level option / logging.configure call so CLI logging stays off and only the prompt reaches stdout, and remove two stray debug print()s (default_modes/base_modes) that leaked onto stdout. IDE autostart is already disabled here via with_headless_mode_overrides() (it nulls jetbrains_launch_command). Also drop an accidental unused sqlalchemy import. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/serena/cli.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index b13d3860..453546af 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -395,13 +395,7 @@ class TopLevelCommands(AutoRegisteringGroup): @click.command( "print-system-prompt", help="Print the system prompt for a project.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} ) - @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) - @click.option( - "--log-level", - type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), - default="WARNING", - help="Log level for prompt generation.", - ) + @click.argument("project", type=click.Path(exists=True), default=None, required=False) @click.option("--only-instructions", is_flag=True, help="Print only the initial instructions, without prefix/postfix.") @click.option( "--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML." @@ -415,26 +409,19 @@ class TopLevelCommands(AutoRegisteringGroup): show_default=False, help=_MODES_EXPLANATION, ) - def print_system_prompt( - project: str, log_level: str, only_instructions: bool, context: str, modes: Sequence[str] | None = None - ) -> None: + def print_system_prompt(project: str | None, only_instructions: bool, context: str, modes: Sequence[str] | None = None) -> None: from serena.agent import SerenaAgent prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account." postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks." - lvl = logging.getLevelNamesMapping()[log_level.upper()] - logging.configure(level=lvl) context_instance = SerenaAgentContext.load(context) modes_selection_def: ModeSelectionDefinition | None = None if modes: modes_selection_def = ModeSelectionDefinition(default_modes=modes) serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides() - print(serena_config.default_modes) - print(serena_config.base_modes) - agent = SerenaAgent( - project=os.path.abspath(project), + project=os.path.abspath(project) if project is not None else None, serena_config=serena_config, context=context_instance, modes=modes_selection_def, From 31e2acd9bc705873f4c4b9abe14119c0077b898f Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 29 Jun 2026 15:31:32 +0200 Subject: [PATCH 056/170] Restructure change log entries --- CHANGELOG.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7087b497..2f26ea0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: + - Fix: context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in + incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); + file access is now guarded with path detection (file ending or path separator must be present) + - Adjust prompt generation mechanism to use newly introduced tool name mapping `tool_names`, allowing + prompts to directly use tool names that match the active language backend (and removing the need + for additional prompts that explain tool name differences) + - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) + - Fix: a registered project whose root directory was deleted while Serena was already running could break + `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` + +* CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` now walks up in a single pass so the nearest project boundary wins (either a `.serena/project.yml` or a `.git`, including worktree/submodule pointer files), instead of preferring an ancestor's @@ -10,18 +21,11 @@ Status of the `main` branch. Changes prior to the next official version change w Gemini) launched from inside a worktree to the parent repo, causing stale reads and misdirected edits. - Fix: CLI flags on `start-mcp-server` could incorrectly be saved to the global configuration file if the list of projects was modified (triggering a save of the configuration with transient overrides applied) - - Fix: context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in - incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); - file access is now guarded with path detection (file ending or path separator must be present) - - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration - - Adjust prompt generation mechanism to use newly introduced tool name mapping `tool_names`, allowing - prompts to directly use tool names that match the active language backend (and removing the need - for additional prompts that explain tool name differences) - - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) + +* Tools: - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools + - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration - Make tool call errors surface explicitly as errors at the MCP protocol level - - Fix: a registered project whose root directory was deleted while Serena was already running could break - `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` * Language Servers: - C/C++ (clangd): add Unreal Engine 5 fixture and tests verifying that reflection-macro From 4dc0cd1453ff50cd57c4aae8125616b6ad9dffce Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Wed, 10 Jun 2026 18:52:58 +0300 Subject: [PATCH 057/170] Add ReplaceInFilesTool --- CHANGELOG.md | 1 + src/serena/tools/file_tools.py | 242 ++++++++++++++++++++++++++++++- src/serena/util/text_utils.py | 157 ++++++++++++++++++++ test/serena/test_serena_agent.py | 52 +++++++ test/serena/test_text_utils.py | 64 +++++++- 5 files changed, 514 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f26ea0c..c3795e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Status of the `main` branch. Changes prior to the next official version change w list of projects was modified (triggering a save of the configuration with transient overrides applied) * Tools: + - New tool: `replace_in_files` - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration - Make tool call errors surface explicitly as errors at the MCP protocol level diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 2b7f1a9b..dd61903a 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -14,7 +14,14 @@ from typing import Literal from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagnostics, Tool, ToolMarkerOptional from serena.util.file_system import scan_directory -from serena.util.text_utils import ContentReplacer, search_files +from serena.util.text_utils import ( + ContentReplacer, + MultiFileContentReplacer, + ReplacementOccurrence, + expand_braces, + glob_match, + search_files, +) class ReadFileTool(Tool): @@ -225,6 +232,239 @@ class ReplaceContentTool(EditingToolWithDiagnostics): return diagnostics_context.format_result(SUCCESS_RESULT) +class ReplaceInFilesTool(EditingToolWithDiagnostics): + """ + Replaces occurrences of a pattern across multiple files, with dry-run preview and per-occurrence selection. + """ + + def apply( + self, + needle: str, + repl: str, + mode: Literal["literal", "regex"], + relative_path: str = "", + paths_include_glob: str = "", + paths_exclude_glob: str = "", + dry_run: bool = False, + occurrence_ids: list[str] | None = None, + expected_count: int = -1, + max_answer_chars: int = -1, + ) -> str: + r""" + Replaces occurrences of a pattern across multiple files in ONE call. + + This is the preferred tool for repeated small edits (renames, import swaps, annotation changes, + path prefixes) spanning several files or many places in one file: one call with a SHORT pattern + replaces many single-file replacements with long disambiguating needles. + + Recommended protocol whenever there is ANY risk of unintended replacements: + 1. Call with dry_run=True: every prospective change is returned as a minimal line diff with an + occurrence id; nothing is modified. + 2. Call again with dry_run=False, passing the ids you want in occurrence_ids (omit it to apply + all). You pick the desired replacements from the list - no counting, no needle-crafting. + + For clearly unambiguous bulk replacements you may skip the dry run; pass expected_count as a + guard. If the actual number of matches differs, NOTHING is changed and the diff list is + returned, so a failed guard costs one call and gives you the dry-run output to select from. + + :param needle: the string (mode "literal") or regular expression (mode "regex"; Python `re` + syntax with DOTALL and MULTILINE) to search for + :param repl: the replacement string. In regex mode, backreferences to matched groups can be + specified as $!1, $!2, etc. + :param mode: either "literal" or "regex", specifying how `needle` is to be interpreted + :param relative_path: only consider this file or directory (default: the whole project) + :param paths_include_glob: optional glob (relative to the project root, e.g. "src/**/*.java") + restricting which files are considered + :param paths_exclude_glob: optional glob of files to exclude; takes precedence over the include glob + :param dry_run: if True, do not modify anything; return the prospective changes as a list of + diffs with occurrence ids + :param occurrence_ids: optional list of occurrence ids (obtained from a dry run) to which the + replacement is restricted; if any id is unknown or stale, NOTHING is changed. If omitted, + all occurrences are replaced. + :param expected_count: optional guard for calls without occurrence_ids: the number of + occurrences you expect to be replaced. If the actual count differs, nothing is changed and + the list of prospective changes is returned. -1 disables the guard. + :param max_answer_chars: if the output exceeds this many characters, a shortened version is + returned. -1 uses the configured default. + :return: in a dry run, the prospective changes; otherwise a summary of the applied replacements + """ + replacer = MultiFileContentReplacer(mode=mode) + files = self._collect_files(relative_path, paths_include_glob, paths_exclude_glob) + occurrences = replacer.find_occurrences(files, needle, repl) + contents = dict(files) + + if dry_run: + return self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=True) + + if occurrence_ids is not None: + selected, problems = self._resolve_occurrence_ids(occurrence_ids, occurrences) + if problems: + problem_lines = "\n".join(f" {p}" for p in problems) + raise ValueError( + f"{len(problems)} of the given occurrence_ids could not be resolved - NO changes were applied:\n" + f"{problem_lines}\n" + "Re-run with dry_run=True to obtain current occurrence ids." + ) + if not selected: + raise ValueError("occurrence_ids is empty - pass at least one id from a dry run, or omit the parameter to replace all.") + return self._apply_occurrences(replacer, selected, contents, needle, repl) + + # blind apply (no ids) + if not occurrences: + raise ValueError( + "No occurrences of the pattern were found - NO changes were applied. " + "Check the mode (a literal needle containing regex metacharacters must use mode 'literal'; " + "wildcards require mode 'regex') and the path/glob restrictions, " + "or locate the content with search_for_pattern first." + ) + if expected_count >= 0 and len(occurrences) != expected_count: + listing = self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=False) + raise ValueError( + f"expected_count={expected_count}, but the pattern matches {len(occurrences)} occurrence(s) - " + f"NO changes were applied. Review the prospective changes below; re-issue with the corrected " + f"expectation, a refined pattern, or occurrence_ids selecting the intended subset.\n{listing}" + ) + ambiguous = [o for o in occurrences if o.is_ambiguous] + if ambiguous: + listing = self._render_listing(replacer, occurrences, contents, max_answer_chars, dry_run=False) + raise ValueError( + f"{len(ambiguous)} occurrence(s) are ambiguous (the pattern matches again inside the matched text, " + f"indicating possible over-matching) - NO changes were applied. Review the prospective changes below " + f"and either refine the pattern or explicitly select occurrences via occurrence_ids.\n{listing}" + ) + return self._apply_occurrences(replacer, occurrences, contents, needle, repl) + + def _collect_files(self, relative_path: str, paths_include_glob: str, paths_exclude_glob: str) -> list[tuple[str, str]]: + """Collects (relative_path, content) pairs of the non-ignored files in scope, in sorted path order.""" + relative_path = relative_path.strip() + if relative_path: + self.project.validate_relative_path(relative_path, require_not_ignored=True) + abs_path = os.path.join(self.get_project_root(), relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError(f"Relative path {relative_path} does not exist.") + if os.path.isfile(abs_path): + rel_paths = [relative_path] + else: + _dirs, rel_paths = scan_directory( + path=abs_path, + recursive=True, + is_ignored_dir=self.project.is_ignored_path, + is_ignored_file=self.project.is_ignored_path, + relative_to=self.get_project_root(), + ) + include_patterns = expand_braces(paths_include_glob.strip()) if paths_include_glob.strip() else None + exclude_patterns = expand_braces(paths_exclude_glob.strip()) if paths_exclude_glob.strip() else None + files: list[tuple[str, str]] = [] + for path in sorted(rel_paths): + if include_patterns and not any(glob_match(p, path) for p in include_patterns): + continue + if exclude_patterns and any(glob_match(p, path) for p in exclude_patterns): + continue + try: + files.append((path, self.project.read_file(path))) + except Exception: + continue # skip unreadable (e.g. binary) files + return files + + def _render_listing( + self, + replacer: MultiFileContentReplacer, + occurrences: list[ReplacementOccurrence], + contents: dict[str, str], + max_answer_chars: int, + dry_run: bool, + ) -> str: + affected_files = sorted({o.relative_path for o in occurrences}) + header = f"Found {len(occurrences)} occurrence(s) in {len(affected_files)} file(s)." + if dry_run: + header += ( + " DRY RUN - no changes were applied.\n" + "Re-issue with dry_run=False to replace all of them, or additionally pass occurrence_ids " + "with the ids of the occurrences to replace." + ) + parts = [header] + for path in affected_files: + file_occurrences = [o for o in occurrences if o.relative_path == path] + parts.append(f"\n{path} ({len(file_occurrences)} occurrence(s)):") + for occ in file_occurrences: + parts.append(replacer.render_occurrence_diff(occ, contents[path])) + result = "\n".join(parts) + + def make_locations_only() -> str: + lines = [header] + [f" [{o.occurrence_id}] line {o.start_line}" for o in occurrences] + return "\n".join(lines) + + def make_per_file_counts() -> str: + counts = {path: sum(1 for o in occurrences if o.relative_path == path) for path in affected_files} + return f"{header}\nOccurrence counts per file:\n{self._to_json(counts)}" + + def make_summary() -> str: + return header + + return self._limit_length( + result, max_answer_chars, shortened_result_factories=[make_locations_only, make_per_file_counts, make_summary] + ) + + @staticmethod + def _resolve_occurrence_ids( + occurrence_ids: list[str], occurrences: list[ReplacementOccurrence] + ) -> tuple[list[ReplacementOccurrence], list[str]]: + """Resolves the requested ids against the current occurrences, diagnosing each failure.""" + occurrences_by_id = {o.occurrence_id: o for o in occurrences} + indices_by_path: dict[str, set[int]] = {} + for o in occurrences: + indices_by_path.setdefault(o.relative_path, set()).add(o.index_in_file) + selected: dict[str, ReplacementOccurrence] = {} + problems: list[str] = [] + for oid in occurrence_ids: + occurrence = occurrences_by_id.get(oid) + if occurrence is not None: + selected[oid] = occurrence + continue + id_match = MultiFileContentReplacer.OCCURRENCE_ID_REGEX.match(oid) + if id_match is None: + problems.append(f"{oid}: malformed id (expected '<path>:<index>@<digest>' as returned by a dry run)") + elif id_match.group("path") not in indices_by_path: + problems.append(f"{oid}: the pattern currently has no matches in this file") + elif int(id_match.group("index")) not in indices_by_path[id_match.group("path")]: + problems.append(f"{oid}: the file now has fewer matches than at dry-run time (content changed)") + else: + problems.append(f"{oid}: the matched text changed since the dry run (content changed)") + return list(selected.values()), problems + + def _apply_occurrences( + self, + replacer: MultiFileContentReplacer, + occurrences: list[ReplacementOccurrence], + contents: dict[str, str], + needle: str, + repl: str, + ) -> str: + occurrences_by_file: dict[str, list[ReplacementOccurrence]] = {} + for occ in occurrences: + occurrences_by_file.setdefault(occ.relative_path, []).append(occ) + with self.DiagnosticsContext(self, *occurrences_by_file.keys()) as diagnostics_context: + code_editor = self.create_code_editor() + for path, file_occurrences in occurrences_by_file.items(): + with EditedFileContext(path, code_editor) as context: + original_content = context.get_original_content() + if original_content != contents[path]: + # the editor's view differs from what was scanned (e.g. line-ending normalization); + # re-derive the occurrences from the authoritative content and re-validate by id + fresh_by_id = {o.occurrence_id: o for o in replacer.find_occurrences([(path, original_content)], needle, repl)} + try: + file_occurrences = [fresh_by_id[o.occurrence_id] for o in file_occurrences] + except KeyError as e: + raise ValueError( + f"The content of {path} changed while replacing (occurrence {e} no longer resolves); " + f"the file was NOT modified. Re-run with dry_run=True for current ids." + ) from e + context.set_updated_content(replacer.apply_to_content(original_content, file_occurrences)) + per_file = "\n".join(f" {path}: {len(occs)}" for path, occs in occurrences_by_file.items()) + summary = f"Replaced {len(occurrences)} occurrence(s) in {len(occurrences_by_file)} file(s):\n{per_file}" + return diagnostics_context.format_result(summary) + + class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Deletes a range of lines within a file. diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 16bee9d9..aff92fdb 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -1,4 +1,5 @@ import fnmatch +import hashlib import logging import os import re @@ -528,6 +529,162 @@ class ContentReplacer: return updated_content +@dataclass +class ReplacementOccurrence: + """A single prospective replacement of a pattern match within one file.""" + + occurrence_id: str + """stable, content-anchored identifier: '<relative_path>:<index_in_file>@<digest>'""" + relative_path: str + index_in_file: int + """0-based index of this match among the matches within its file (in position order)""" + start: int + """character offset of the match start within the file content""" + end: int + """character offset of the match end within the file content""" + matched_text: str + replacement: str + """the fully expanded replacement text (backreferences already resolved)""" + start_line: int + """0-based line number of the match start""" + end_line: int + """0-based line number of the match end""" + is_ambiguous: bool = False + """whether the pattern matches again within the matched text (possible over-match)""" + + +class MultiFileContentReplacer: + """ + Occurrence-level counterpart of :class:`ContentReplacer` operating on multiple files: + finds every match of a pattern across a set of file contents, assigns each occurrence a + stable content-anchored id, renders minimal line diffs for previewing, and computes the + updated content of a file for a selected subset of occurrences. + """ + + OCCURRENCE_ID_REGEX = re.compile(r"^(?P<path>.+):(?P<index>\d+)@(?P<digest>[0-9a-f]{6})$") + _DIGEST_LEN = 6 + + def __init__(self, mode: Literal["literal", "regex"], regex_multiline: bool = True): + """ + :param mode: whether the needle is a literal string ("literal") or a regular expression ("regex") + :param regex_multiline: whether to apply multi-line regex matching, enabling the flags re.DOTALL and re.MULTILINE + """ + if mode not in ("literal", "regex"): + raise ValueError(f"Invalid mode: '{mode}', expected 'literal' or 'regex'.") + self.mode = mode + self._flags = (re.MULTILINE | re.DOTALL) if regex_multiline else 0 + + def _compile(self, needle: str) -> re.Pattern: + return re.compile(re.escape(needle) if self.mode == "literal" else needle, flags=self._flags) + + @classmethod + def _digest(cls, matched_text: str) -> str: + return hashlib.sha1(matched_text.encode("utf-8")).hexdigest()[: cls._DIGEST_LEN] + + @classmethod + def make_occurrence_id(cls, relative_path: str, index_in_file: int, matched_text: str) -> str: + return f"{relative_path}:{index_in_file}@{cls._digest(matched_text)}" + + @staticmethod + def _expand_backreferences(match: re.Match, repl_template: str) -> str: + """Expands $!1, $!2, ... in the replacement template (same syntax as :class:`ContentReplacer`).""" + + def expand(m: re.Match) -> str: + group_value = match.group(int(m.group(1))) + return group_value if group_value is not None else m.group(0) + + return re.sub(r"\$!(\d+)", expand, repl_template) + + def find_occurrences(self, files: list[tuple[str, str]], needle: str, repl: str) -> list[ReplacementOccurrence]: + """ + Finds all matches of the needle in the given files. + + :param files: (relative_path, content) pairs; processed in the given order + :param needle: the search expression (literal string or regex, depending on the mode) + :param repl: the replacement template (may contain $!N backreferences in regex mode) + :return: occurrences in deterministic order (file order, then position within the file) + """ + pattern = self._compile(needle) + occurrences: list[ReplacementOccurrence] = [] + for relative_path, content in files: + for index_in_file, match in enumerate(pattern.finditer(content)): + matched_text = match.group(0) + replacement = self._expand_backreferences(match, repl) if self.mode == "regex" else repl + # same over-match heuristic as ContentReplacer: for a multi-line match, the pattern + # matching again within the matched text indicates the match may have swallowed + # more than intended + is_ambiguous = "\n" in matched_text and pattern.search(matched_text[1:]) is not None + occurrences.append( + ReplacementOccurrence( + occurrence_id=self.make_occurrence_id(relative_path, index_in_file, matched_text), + relative_path=relative_path, + index_in_file=index_in_file, + start=match.start(), + end=match.end(), + matched_text=matched_text, + replacement=replacement, + start_line=content.count("\n", 0, match.start()), + end_line=content.count("\n", 0, match.end()), + is_ambiguous=is_ambiguous, + ) + ) + return occurrences + + @staticmethod + def apply_to_content(content: str, occurrences: list[ReplacementOccurrence]) -> str: + """ + Applies the given occurrences (which must have been derived from exactly this content) + and returns the updated content. + """ + for occ in sorted(occurrences, key=lambda o: o.start, reverse=True): + assert content[occ.start : occ.end] == occ.matched_text, ( + f"Occurrence {occ.occurrence_id} does not match the content it is being applied to" + ) + content = content[: occ.start] + occ.replacement + content[occ.end :] + return content + + @staticmethod + def _format_block(block: str, prefix: str, max_lines: int, max_line_chars: int) -> list[str]: + lines = block.split("\n") + shown = lines[:max_lines] + result = [] + for line in shown: + if len(line) > max_line_chars: + line = line[:max_line_chars] + f"… (+{len(line) - max_line_chars} chars)" + result.append(f" {prefix} {line}") + if len(lines) > max_lines: + result.append(f" {prefix} … ({len(lines) - max_lines} more lines)") + return result + + def render_occurrence_diff( + self, occ: ReplacementOccurrence, content: str, max_lines_per_side: int = 6, max_line_chars: int = 200 + ) -> str: + """ + Renders a minimal line diff for the occurrence: the full lines spanned by the match, + before and after the replacement. + + :param occ: the occurrence (must have been derived from exactly this content) + :param content: the file content the occurrence was found in + :param max_lines_per_side: cap on the number of displayed lines per diff side + :param max_line_chars: cap on the number of displayed characters per line + :return: the rendered diff + """ + line_start = content.rfind("\n", 0, occ.start) + 1 + line_end = content.find("\n", occ.end) + if line_end == -1: + line_end = len(content) + old_block = content[line_start:line_end] + new_block = content[line_start : occ.start] + occ.replacement + content[occ.end : line_end] + location = f"line {occ.start_line}" if occ.start_line == occ.end_line else f"lines {occ.start_line}-{occ.end_line}" + header = f" [{occ.occurrence_id}] {location}" + if occ.is_ambiguous: + header += " (WARNING: the pattern matches again inside this match — possible over-match, verify the diff)" + diff_lines = [header] + diff_lines += self._format_block(old_block, "-", max_lines_per_side, max_line_chars) + diff_lines += self._format_block(new_block, "+", max_lines_per_side, max_line_chars) + return "\n".join(diff_lines) + + @dataclass class TextCoords: line: int diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 5ab27115..dd8a8407 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -26,6 +26,7 @@ from serena.tools import ( GetDiagnosticsForFileTool, InitialInstructionsTool, ReplaceContentTool, + ReplaceInFilesTool, ReplaceSymbolBodyTool, SafeDeleteSymbol, Tool, @@ -1171,6 +1172,57 @@ class TestSerenaAgent: new_content = read_project_file(serena_agent.get_active_project(), relative_path) assert repl in new_content + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_replace_in_files"), + ], + indirect=["serena_agent"], + ) + def test_replace_in_files_dry_run_then_selective_apply(self, serena_agent: SerenaAgent): + """A dry run lists every occurrence as a diff with an id and modifies nothing; a follow-up call + restricted to one of the ids replaces exactly that occurrence. + """ + relative_path = os.path.join("test_repo", "models.py") + needle = "name: str | None = None" + repl = "name: str | None = MARKER_DEFAULT" + tool = serena_agent.get_tool(ReplaceInFilesTool) + with project_file_modification_context(serena_agent, relative_path): + original_content = read_project_file(serena_agent.get_active_project(), relative_path) + assert original_content.count(needle) >= 2 + + listing = tool.apply(needle=needle, repl=repl, mode="literal", relative_path=relative_path, dry_run=True) + assert "DRY RUN" in listing + occurrence_ids = re.findall(r"\[([^\[\]]+:\d+@[0-9a-f]{6})\]", listing) + assert len(occurrence_ids) == original_content.count(needle) + assert read_project_file(serena_agent.get_active_project(), relative_path) == original_content + + result = tool.apply(needle=needle, repl=repl, mode="literal", relative_path=relative_path, occurrence_ids=[occurrence_ids[0]]) + assert "Replaced 1 occurrence(s) in 1 file(s)" in result + new_content = read_project_file(serena_agent.get_active_project(), relative_path) + assert new_content.count("MARKER_DEFAULT") == 1 + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_replace_in_files_guard"), + ], + indirect=["serena_agent"], + ) + def test_replace_in_files_expected_count_guard_returns_listing(self, serena_agent: SerenaAgent): + """A blind call with a wrong expected_count must change nothing and return the prospective + changes (the failed guard doubles as a dry run). + """ + relative_path = os.path.join("test_repo", "models.py") + needle = "name: str | None = None" + tool = serena_agent.get_tool(ReplaceInFilesTool) + with project_file_modification_context(serena_agent, relative_path): + original_content = read_project_file(serena_agent.get_active_project(), relative_path) + with pytest.raises(ValueError, match="NO changes were applied") as exc_info: + tool.apply(needle=needle, repl="X", mode="literal", relative_path=relative_path, expected_count=1) + assert re.search(r"\[[^\[\]]+:\d+@[0-9a-f]{6}\]", str(exc_info.value)) # the listing with ids is included + assert read_project_file(serena_agent.get_active_project(), relative_path) == original_content + @pytest.mark.parametrize( "serena_agent", [ diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index e89ccf71..e6cd4539 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -2,7 +2,7 @@ import re import pytest -from serena.util.text_utils import LineType, search_files, search_text +from serena.util.text_utils import LineType, MultiFileContentReplacer, search_files, search_text class TestSearchText: @@ -575,3 +575,65 @@ class TestExpandBraces: from serena.util.text_utils import expand_braces assert sorted(expand_braces(pattern)) == sorted(expected) + + +class TestMultiFileContentReplacer: + FILES = [ + ("a/first.py", "import old_pkg\n\nvalue = old_pkg.compute()\n"), + ("b/second.py", "import old_pkg\nother = 1\n"), + ] + + def test_find_occurrences_order_ids_and_lines(self): + replacer = MultiFileContentReplacer(mode="literal") + occurrences = replacer.find_occurrences(self.FILES, "old_pkg", "new_pkg") + assert [o.relative_path for o in occurrences] == ["a/first.py", "a/first.py", "b/second.py"] + assert [o.index_in_file for o in occurrences] == [0, 1, 0] + assert [o.start_line for o in occurrences] == [0, 2, 0] + for o in occurrences: + assert o.matched_text == "old_pkg" + assert o.replacement == "new_pkg" + assert MultiFileContentReplacer.OCCURRENCE_ID_REGEX.match(o.occurrence_id) + # ids are content-anchored: same matched text at the same index yields the same id across calls + again = replacer.find_occurrences(self.FILES, "old_pkg", "new_pkg") + assert [o.occurrence_id for o in again] == [o.occurrence_id for o in occurrences] + + def test_regex_backreference_expansion(self): + replacer = MultiFileContentReplacer(mode="regex") + files = [("f.txt", "name=alpha\nname=beta\n")] + occurrences = replacer.find_occurrences(files, r"name=(\w+)", "id=$!1") + assert [o.replacement for o in occurrences] == ["id=alpha", "id=beta"] + + def test_apply_to_content_selected_subset(self): + replacer = MultiFileContentReplacer(mode="literal") + path, content = self.FILES[0] + occurrences = [o for o in replacer.find_occurrences(self.FILES, "old_pkg", "new_pkg") if o.relative_path == path] + updated = replacer.apply_to_content(content, occurrences[1:]) # only the second occurrence + assert updated == "import old_pkg\n\nvalue = new_pkg.compute()\n" + updated_all = replacer.apply_to_content(content, occurrences) + assert updated_all == "import new_pkg\n\nvalue = new_pkg.compute()\n" + + def test_ambiguous_multiline_match_is_flagged(self): + replacer = MultiFileContentReplacer(mode="regex") + files = [("f.txt", "start A\nstart B\nend\n")] + occurrences = replacer.find_occurrences(files, r"start.*?end", "X") + assert len(occurrences) == 1 + assert occurrences[0].is_ambiguous + + def test_render_occurrence_diff_minimal_lines(self): + replacer = MultiFileContentReplacer(mode="literal") + path, content = self.FILES[0] + occ = replacer.find_occurrences([(path, content)], "old_pkg.compute()", "new_pkg.compute_all()")[0] + diff = replacer.render_occurrence_diff(occ, content) + assert occ.occurrence_id in diff + assert "line 2" in diff + assert " - value = old_pkg.compute()" in diff + assert " + value = new_pkg.compute_all()" in diff + # only the affected line is shown, not the whole file + assert "import old_pkg" not in diff + + def test_apply_to_content_rejects_drifted_occurrence(self): + replacer = MultiFileContentReplacer(mode="literal") + path, content = self.FILES[0] + occ = replacer.find_occurrences([(path, content)], "old_pkg", "new_pkg")[0] + with pytest.raises(AssertionError): + replacer.apply_to_content("completely different content", [occ]) From 75410c78e780c762268c660912f3e5554ea6550b Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Fri, 12 Jun 2026 18:08:11 +0200 Subject: [PATCH 058/170] Use depth=-1 to enable lang-specific default for overview tools --- CHANGELOG.md | 1 + src/serena/tools/jetbrains_tools.py | 11 +++++++++-- src/serena/tools/symbol_tools.py | 12 +++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3795e43..29582122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Tools: - New tool: `replace_in_files` + - `get_symbols_overview`, `jet_brains_get_symbols_overview`: Improved default for `depth` parameter - Add tool parameter alias support, adding `name_path` as an alias for `name_path_pattern` in `find_symbol` tools - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration - Make tool call errors surface explicitly as errors at the MCP protocol level diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 3e71c45f..c29d58ce 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -323,7 +323,7 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp def apply( self, relative_path: str, - depth: int = 0, + depth: int = -1, max_answer_chars: int = -1, include_file_documentation: bool = False, ) -> str: @@ -335,10 +335,17 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp or by using the `list_dir` and `find_file` tools (or similar). :param relative_path: the relative path to the file to get the overview of - :param depth: depth up to which descendants shall be retrieved (e.g., use 1 to also retrieve immediate children). + :param depth: depth up to which descendants shall be retrieved. + Default (-1) results in a language specific choice: 1 for java and kotlin and 0 for other languages :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. :param include_file_documentation: whether to include the file's docstring. Default False. """ + if depth == -1: + if relative_path.endswith((".java", ".kt")): + depth = 1 + else: + depth = 0 + relative_path = self._sanitize_input_param(relative_path) with JetBrainsPluginClient.from_project(self.project) as client: symbol_overview = client.get_symbols_overview( diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 7c11fce4..a5d12d54 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -40,20 +40,26 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): symbol_dict_grouper = LanguageServerSymbolDictGrouper(["kind"], ["kind"], collapse_singleton=True) - def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1) -> str: + def apply(self, relative_path: str, depth: int = -1, max_answer_chars: int = -1) -> str: """ Use this tool to get a high-level understanding of the code symbols in a file. This should be the first tool to call when you want to understand a new file, unless you already know what you are looking for. :param relative_path: the relative path to the file to get the overview of - :param depth: depth up to which descendants of top-level symbols shall be retrieved - (e.g. 1 retrieves immediate children). Default 0. + :param depth: depth up to which descendants shall be retrieved. + Default (-1) results in a language specific choice: 1 for java and kotlin and 0 for other languages :param max_answer_chars: if the overview is longer than this number of characters, no content will be returned. -1 means the default value from the config will be used. Don't adjust unless there is really no other way to get the content required for the task. :return: a JSON object containing symbols grouped by kind in a compact format. """ + if depth == -1: + if relative_path.endswith((".java", ".kt")): + depth = 1 + else: + depth = 0 + result = self.get_symbol_overview(relative_path, depth=depth) # capture kind names and depth-0 snapshots before grouping, which mutates the dicts From b174a2cf729f61a8d44c279bab52cfa0d7670fd6 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Sun, 14 Jun 2026 23:58:07 +0200 Subject: [PATCH 059/170] util/yaml: make save_yaml atomic (temp + os.replace, Windows retry) A plain truncate-and-write (open(path,"w")) is not atomic: a concurrent writer (a second Serena process updating the auto-managed registered-projects list) or an interrupted write can leave the file half-overwritten -- a shorter value over a longer one leaves a stale tail, corrupting the YAML so every later load fails. This is what wedged native benchmark runs (serena MCP crashed at startup with a ruamel ScannerError -> 0 tools registered -> agent silently degraded to Bash). Write to a temp file in the same dir, then os.replace onto the target (atomic; last-writer-wins, never a corrupt interleave). Retry os.replace on Windows PermissionError (WinError 5/32 sharing violation under contention). Adds test/serena/util/test_yaml.py covering the shorter-over-longer corruption signature plus a concurrent-writers stress. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/serena/util/yaml.py | 42 ++++++++++++++++-- test/serena/util/test_yaml.py | 80 +++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 test/serena/util/test_yaml.py diff --git a/src/serena/util/yaml.py b/src/serena/util/yaml.py index ca63eee7..bc16f431 100644 --- a/src/serena/util/yaml.py +++ b/src/serena/util/yaml.py @@ -1,5 +1,7 @@ import logging import os +import tempfile +import time from collections.abc import Sequence from enum import Enum from typing import Any @@ -199,9 +201,43 @@ def normalise_yaml_comments(commented_map: CommentedMap, comment_normalisation: def save_yaml(path: str, data: dict | CommentedMap, preserve_comments: bool = True) -> None: yaml = _create_yaml(preserve_comments) - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding=SERENA_FILE_ENCODING) as f: - yaml.dump(data, f) + target_dir = os.path.dirname(path) + os.makedirs(target_dir, exist_ok=True) + # Atomic write: dump to a temp file in the SAME directory, then os.replace onto the target. + # A plain truncate-and-write (open(path, "w")) is NOT atomic: a concurrent writer (e.g. a second + # Serena process updating the auto-managed registered-projects list) or an interrupted write can + # leave the file half-overwritten — writing a shorter value over a longer one leaves a stale tail, + # which corrupts the YAML so it no longer parses and every later load fails. temp + os.replace makes + # each write all-or-nothing (last-writer-wins, never a corrupt interleave). + fd, tmp = tempfile.mkstemp(dir=target_dir, prefix=os.path.basename(path) + ".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding=SERENA_FILE_ENCODING) as f: + yaml.dump(data, f) + _replace_with_retry(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +def _replace_with_retry(src: str, dst: str, *, attempts: int = 10, delay_s: float = 0.05) -> None: + """``os.replace(src, dst)`` with a short retry on a Windows sharing violation. + + On Windows the atomic rename fails with ``PermissionError`` (WinError 5/32) if another process + momentarily holds ``dst`` open — e.g. a second Serena process reading or replacing the same + config. A brief bounded retry rides out that contention; the temp file is still complete, so we + never fall back to a non-atomic write. + """ + for attempt in range(attempts): + try: + os.replace(src, dst) + return + except PermissionError: + if attempt == attempts - 1: + raise + time.sleep(delay_s) def yaml_comment_entry_is_empty(comment_entry: Any) -> bool: diff --git a/test/serena/util/test_yaml.py b/test/serena/util/test_yaml.py new file mode 100644 index 00000000..a64125ba --- /dev/null +++ b/test/serena/util/test_yaml.py @@ -0,0 +1,80 @@ +"""Tests for serena.util.yaml — focused on the atomic-write guarantee of ``save_yaml``. + +Regression: ``~/.serena/serena_config.yml`` was corrupted in the field when a non-atomic +truncate-and-write saved a SHORTER value over a longer existing file (or two Serena processes +saved concurrently), leaving a stale tail (e.g. a dangling ``ena`` line) that made the YAML +unparseable, which wedged every later load. ``save_yaml`` now writes to a temp file and +``os.replace``s it onto the target, so each write is all-or-nothing. +""" + +import os +import threading + +from ruamel.yaml import YAML + +from serena.util.yaml import load_yaml, save_yaml + + +def _read_text(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def test_save_yaml_roundtrips(tmp_path): + path = str(tmp_path / "config.yml") + save_yaml(path, {"projects": ["a", "b", "c"], "scalar": 1}) + loaded = load_yaml(path) + assert list(loaded["projects"]) == ["a", "b", "c"] + assert loaded["scalar"] == 1 + + +def test_shorter_write_over_longer_leaves_no_stale_tail(tmp_path): + """The exact field corruption: a long file overwritten by a much shorter one. + + A plain ``open(path, "w")`` truncates first, so this would pass even when buggy; the point + of the assertion is that the final file parses and contains EXACTLY the new content — no + leftover bytes from the longer previous version. + """ + path = str(tmp_path / "config.yml") + long_projects = [f"C:/Users/misch/Projects/some/really/long/path/number/{i}/serena" for i in range(50)] + save_yaml(path, {"projects": long_projects}) + + save_yaml(path, {"projects": ["C:/Users/misch/Projects/oraios/serena"]}) + + loaded = load_yaml(path) + assert list(loaded["projects"]) == ["C:/Users/misch/Projects/oraios/serena"] + # no stale tail from the longer write survived + assert "number/49" not in _read_text(path) + # and it is still valid YAML on a fresh strict parse + with open(path, encoding="utf-8") as f: + YAML().load(f) + + +def test_no_tmp_files_left_behind(tmp_path): + path = str(tmp_path / "config.yml") + save_yaml(path, {"projects": ["a"]}) + leftovers = [p for p in os.listdir(tmp_path) if p != "config.yml"] + assert leftovers == [], f"temp files not cleaned up: {leftovers}" + + +def test_concurrent_saves_never_corrupt(tmp_path): + """Many concurrent writers must yield a file that always parses (last-writer-wins is fine; + a corrupt interleave is not). + """ + path = str(tmp_path / "config.yml") + save_yaml(path, {"projects": ["seed"]}) + + def writer(n: int) -> None: + for _ in range(20): + save_yaml(path, {"projects": [f"p{n}-{k}" for k in range(n + 1)]}) + + threads = [threading.Thread(target=writer, args=(n,)) for n in range(6)] + for t in threads: + t.start() + for t in threads: + t.join() + + # whatever won, the file must be complete + parseable (no corruption) + loaded = load_yaml(path) + assert "projects" in loaded + assert all(isinstance(p, str) for p in loaded["projects"]) From 310a01c1acb56c5d594f91149c8b7e3743667b52 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Mon, 22 Jun 2026 23:29:05 +0200 Subject: [PATCH 060/170] Bugfix: list_memories was not recursing into symlinked dirs. --- CHANGELOG.md | 5 +++- src/serena/memories/memory_manager.py | 21 +++++++++++++++-- test/serena/test_memories_manager.py | 34 +++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29582122..891649d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,10 @@ Status of the `main` branch. Changes prior to the next official version change w - Tray manager: Add NixOS-support for AppIndicator-based trays (e.g., most Wayland-trays) to the package in flake.nix. - Fix: Wait for the subprocess that opens the browser window, preventing zombie processes #1488 -Dependencies: +* Memories: + - Make memory iteration follow symbolic links + +* Dependencies: - Add dependency `oslex` # v1.5.3 (2026-05-26) diff --git a/src/serena/memories/memory_manager.py b/src/serena/memories/memory_manager.py index 76c24447..60e5e7d8 100644 --- a/src/serena/memories/memory_manager.py +++ b/src/serena/memories/memory_manager.py @@ -2,7 +2,8 @@ import logging import os import re import shutil -from collections.abc import Sequence +import sys +from collections.abc import Iterator, Sequence from pathlib import Path from typing import Literal @@ -233,11 +234,27 @@ class MemoryManager: def get_full_list(self) -> list[str]: return sorted(self.memories + self.read_only_memories) + @staticmethod + def _iter_memory_files(search_dir: Path) -> Iterator[Path]: + """Yields every ``*.md`` file under ``search_dir``, descending into symlinked directories. + + Directory symlinks must be followed so that memories shared via symlink are discovered + (e.g. a monorepo whose ``.serena/memories`` folder symlinks each submodule's memory + directory, making them addressable as ``<submodule>/<name>``). ``Path.rglob`` only + follows symlinked directories from Python 3.13 onwards, via ``recurse_symlinks``; on + 3.11/3.12 it silently skips them, so we fall back to ``os.walk(followlinks=True)`` there. + ``rglob`` is preferred when available as it is faster than ``os.walk``. + """ + for root, _dirs, files in os.walk(search_dir, followlinks=True): + for filename in files: + if filename.endswith(".md"): + yield Path(root) / filename + def _list_memories(self, search_dir: Path, base_dir: Path, prefix: str = "") -> MemoriesList: result = self.MemoriesList() if not search_dir.exists(): return result - for md_file in search_dir.rglob("*.md"): + for md_file in self._iter_memory_files(search_dir): rel = str(md_file.relative_to(base_dir).with_suffix("")).replace(os.sep, "/") memory_name = prefix + rel if self._is_ignored_memory(memory_name): diff --git a/test/serena/test_memories_manager.py b/test/serena/test_memories_manager.py index 2ba70196..79be6832 100644 --- a/test/serena/test_memories_manager.py +++ b/test/serena/test_memories_manager.py @@ -269,6 +269,40 @@ def _write(manager: MemoryManager, name: str, content: str) -> None: manager.save_memory(name, content, is_tool_context=False) +class TestListMemoriesFollowsSymlinks: + """Regression: memories reachable only through a directory symlink (e.g. a monorepo whose + ``.serena/memories`` symlinks each submodule's memory folder, making them addressable as + ``<submodule>/<name>``) must be listed and readable. ``Path.rglob`` does not descend into + symlinked directories before Python 3.13, which silently hid them from ``list_memories``. + """ + + def test_lists_and_reads_memories_behind_directory_symlink(self, tmp_path) -> None: + project_dir = tmp_path / "project" / ".serena" + manager = MemoryManager(serena_data_folder=project_dir) + _write(manager, "own_memory", "# local") + + # a sibling submodule with its own (possibly nested) memories + submodule_memories = tmp_path / "submodule" / ".serena" / "memories" + (submodule_memories / "nested").mkdir(parents=True) + (submodule_memories / "sub_a.md").write_text("# a", encoding="utf-8") + (submodule_memories / "nested" / "sub_b.md").write_text("# b", encoding="utf-8") + + # symlink the submodule's memory dir into the project's memories folder + link = project_dir / "memories" / "submodule" + try: + link.symlink_to(submodule_memories, target_is_directory=True) + except (OSError, NotImplementedError) as e: + pytest.skip(f"cannot create directory symlinks on this platform/permissions: {e}") + + listed = manager.list_memories().get_full_list() + assert "own_memory" in listed + assert "submodule/sub_a" in listed + assert "submodule/nested/sub_b" in listed + # the listed names must round-trip through the read path (which resolves the symlink) + assert manager.load_memory("submodule/sub_a") == "# a" + assert manager.load_memory("submodule/nested/sub_b") == "# b" + + class TestValidateReferentialIntegrity: def test_clean_report_when_all_references_resolve(self, fs_manager: MemoryManager) -> None: _write(fs_manager, "auth", "# auth notes") From 634460d32d0151a02500966ddf27786d86c12d7b Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Wed, 10 Jun 2026 18:42:06 +0300 Subject: [PATCH 061/170] Add agent instructions on tool descriptions and memories Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .serena/memories/conventions.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.serena/memories/conventions.md b/.serena/memories/conventions.md index 5c18dc52..e7c7acb9 100644 --- a/.serena/memories/conventions.md +++ b/.serena/memories/conventions.md @@ -22,5 +22,13 @@ - Language-server tests are pytest-marker-gated (one marker per language; see `pyproject.toml` `[tool.pytest.ini_options].markers`). Default `poe test` runs unmarked tests + whatever `PYTEST_MARKERS` selects. - Snapshot tests use **syrupy** with custom `--snapshot-patch-pycharm-diff` plugin (auto-added via `addopts`). +## Tool descriptions (LLM-facing) +- To change how a tool is described to the model, edit the Tool class's `apply()` **docstring** (in + `src/serena/tools/*.py`) — `make_mcp_tool` parses the docstring body + `:param:` lines into the MCP tool + description/schema. Do NOT use `tool_description_overrides` in context ymls (e.g. `claude-code.yml`); the + docstring is the single source of truth and overrides drift. Keep `:param:` lines accurate. + ## Memories - Follow `mem:memory_maintenance` for any new/updated memory in `.serena/memories/`. +- Durable knowledge goes in memories/docs (not the assistant's auto-memory); see the monorepo top-level + `CLAUDE.md` for the cross-repo conventions (no benchmark-tuning of agent-facing strings, etc.). From d589d09d85e807565b22e15c3fe5e563a42aff6b Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 26 Jun 2026 11:00:03 +0200 Subject: [PATCH 062/170] Improve interactive mode prompt --- src/serena/resources/config/modes/interactive.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/serena/resources/config/modes/interactive.yml b/src/serena/resources/config/modes/interactive.yml index cdae4e99..74e438b9 100644 --- a/src/serena/resources/config/modes/interactive.yml +++ b/src/serena/resources/config/modes/interactive.yml @@ -1,11 +1,10 @@ description: Interactive mode for clarification and step-by-step work prompt: | - You are operating in interactive mode. You should engage with the user throughout the task, asking for clarification - whenever anything is unclear, insufficiently specified, or ambiguous. - - Break down complex tasks into smaller steps and explain your thinking at each stage. When you're uncertain about - a decision, present options to the user and ask for guidance rather than making assumptions. - + You should engage with the user throughout the task, asking for clarification whenever anything is unclear, + insufficiently specified, or ambiguous. + When you're uncertain about a decision, present options to the user and ask for guidance rather than making assumptions. Focus on providing informative results for intermediate steps, such that the user can follow along with your progress and provide feedback as needed. + This applies unless the user explicitly stated they are unavailable/instructs you to proceed without asking questions. + excluded_tools: [] \ No newline at end of file From 6b0a5ec097513ddc4bff8a4a8eddf69cabb74b0e Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Wed, 10 Jun 2026 18:42:06 +0300 Subject: [PATCH 063/170] Update editing mode prompt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Dominik Jain <dominik.jain@outlook.com> --- src/serena/resources/config/modes/editing.yml | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/serena/resources/config/modes/editing.yml b/src/serena/resources/config/modes/editing.yml index 3c5c038c..f877fc51 100644 --- a/src/serena/resources/config/modes/editing.yml +++ b/src/serena/resources/config/modes/editing.yml @@ -1,18 +1,16 @@ description: All tools, with detailed instructions for code editing -prompt: | - Use symbolic editing tools whenever possible for precise code modifications. - - {% if 'jet_brains_move' in available_tools -%} +prompt: | + Use the symbolic editing tools for precise, structure-aware code modifications. + **Refactoring tools** - You also have access to Serena's refactoring tools (move, inline, safe_delete) - which should ALWAYS be preferred over naive and primitive approaches when applicable. - When you need to move, inline or delete a symbol, you should always try using the corresponding tool first. - {%- endif %} - - {% if 'jet_brains_move' in available_tools -%} - Apart from refactoring tools - {%- endif %} - you have two main approaches for editing code: (a) editing at the symbol level and (b) file-based editing. + For operations on existing symbols, prefer the dedicated refactoring tools over hand-edits: + `{{ tool_names['rename_symbol'] }}` and `{{ tool_names['safe_delete_symbol'] }}`{% if 'jet_brains_move' in available_tools %}, plus `jet_brains_move` and `jet_brains_inline_symbol`{% endif %} — + they are reference-aware and update or check all usages atomically. When such a tool returns success, the + refactoring is already complete and consistent across all declarations, references, overrides and imports — + trust it: do not re-read the changed files or re-run the build / test suite just to confirm the refactor + itself propagated, this would be wasted effort. + + Apart from those refactoring tools, you have two main approaches for editing code: (a) editing at the symbol level and (b) file-based editing. The symbol-based approach is appropriate if you need to adjust an entire symbol, e.g. a method, a class, a function, etc. It is not appropriate if you need to adjust just a few lines of code within a larger symbol. @@ -21,18 +19,23 @@ prompt: | If you need to replace the definition of a symbol, use the `replace_symbol_body` tool. If you want to add some new code at the end of the file, use the `insert_after_symbol` tool with the last top-level symbol in the file. Similarly, you can use `insert_before_symbol` with the first top-level symbol in the file to insert code at the beginning of a file. - You can understand relationships between symbols by using the ``{{ tool_names['find_referencing_symbols'] }}`` tool. If not explicitly requested otherwise by the user, + You can understand relationships between symbols by using the `{{ tool_names['find_referencing_symbols'] }}` tool. If not explicitly requested otherwise by the user, you make sure that when you edit a symbol, the change is either backward-compatible or you find and update all references as needed. The `{{ tool_names['find_referencing_symbols'] }}` tool will give you code snippets around the references as well as symbolic information. - You can assume that all symbol editing tools are reliable, so you never need to verify the results if the tools return without error. + These edits are reliable: once a tool returns without error the change is applied, so you do not need to re-read the file just to confirm it. {% if 'replace_content' in available_tools %} **File-based editing** The `replace_content` tool allows you to perform regex-based replacements within files (as well as simple string replacements). This is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation, e.g. if you need to adjust just a few lines of code within a method. - You are extremely good at regex, so you never need to check whether the replacement produced the correct result. - In particular, you know how to use wildcards effectively in order to avoid specifying the full original text to be replaced! + In `regex` mode, wildcards like `start.*?end` let you match a span without quoting its full text; an ambiguous match + returns an error you can refine, so a tight wildcard pattern is both cheaper and safe. + For several small edits within one file, prefer a batch of targeted `replace_content` calls over rewriting the whole + file: rewriting has to re-emit the file's entire contents, whereas each targeted edit emits only the changed text. + {% if 'replace_in_files' in available_tools %}For the SAME edit across many files, use `replace_in_files`, which applies + it everywhere in one call — equally safe and transparent: its `dry_run` mode first previews every change as a diff with a + per-occurrence id, so you can then apply all of them or just a chosen subset.{% endif %} {% endif %} excluded_tools: - replace_lines From ee3e7506428d08c8786a90306a8bc5cf1734182f Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Wed, 10 Jun 2026 18:42:06 +0300 Subject: [PATCH 064/170] Update Serena system prompt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Dominik Jain <dominik.jain@outlook.com> --- CHANGELOG.md | 1 + .../config/prompt_templates/system_prompt.yml | 30 +++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 891649d9..2cf23d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting/escaping of arguments in shell executions on Windows (via `oslex` dependency) - Fix: a registered project whose root directory was deleted while Serena was already running could break `activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path` + - Update prompts/instructions: Serena instructions manual, modes (editing, interactive) * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index c82b2bb5..78a7ad18 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -5,20 +5,14 @@ prompts: connection_prompt: | CRITICAL: Before starting to work on a coding task, call the `initial_instructions` tool to read the 'Serena Instructions Manual'. system_prompt: | - You have access to semantic coding tools upon which you rely heavily for all your work. - You operate in a resource-efficient and intelligent manner, always keeping in mind to not read or generate - content that is not needed for the task at hand. + You have semantic coding tools that you rely on heavily. Work resource-efficiently: don't read or + generate content the task doesn't need. - Some tasks may require you to understand the architecture of large parts of the codebase, while for others, - it may be enough to read a small set of symbols or a single file. - You avoid reading entire files unless it is absolutely necessary, instead relying on intelligent step-by-step - acquisition of information. {% if 'ToolMarkerSymbolicRead' in available_markers %}Once you have read a full file, it does not make - sense to analyse it with the symbolic read tools; you already have the information.{% endif %} - - You can achieve intelligent reading of code by using the symbolic tools for getting an overview of symbols and - the relations between them, and then only reading the bodies of symbols that are necessary to complete the task at hand. - You can use the standard tools like list_dir, find_file and search_for_pattern if you need to. - Where appropriate, you pass the `relative_path` parameter to restrict the search to a specific file or directory. + Some tasks require understanding a large part of the codebase; others need only a few symbols or a + single file. Avoid reading whole files unless necessary — acquire information step by step, using + the symbolic tools to get an overview of symbols and their relations, then reading only the bodies + you need. {% if 'ToolMarkerSymbolicRead' in available_markers %}Once you have read a full file, there is no point re-analysing it with the symbolic read + tools — you already have it.{% endif %} {% if 'search_for_pattern' in available_tools %} If you are unsure about a symbol's name or location{% if 'find_symbol' in available_tools %} (to the extent that substring_matching for the symbol name is not enough){% endif %}, you can use the `search_for_pattern` tool, which allows fast and flexible search for patterns in the codebase.{% if 'ToolMarkerSymbolicRead' in available_markers %} In this way, you can first find candidates for symbols or files, @@ -26,8 +20,8 @@ prompts: {% endif %} {% if 'ToolMarkerSymbolicRead' in available_markers %} - Symbols are identified by their `name_path` and `relative_path` (see the description of the `{{ tool_names['find_symbol'] }}` tool). - You can get information about the symbols in a file by using the `{{ tool_names['get_symbols_overview'] }}` tool or use the `{{ tool_names['find_symbol'] }}` to search. + Symbols are identified by their `name_path` and `relative_path`. + You can get an overview of the symbols in a file by using the `{{ tool_names['get_symbols_overview'] }}` tool, or search for a specific symbol with `{{ tool_names['find_symbol'] }}`. You only read the bodies of symbols when you need to (e.g. if you want to fully understand or edit it). For example, if you are working with Python code and already know that you need to read the body of the constructor of the class Foo, you can directly use `{{ tool_names['find_symbol'] }}` with name path pattern `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit, @@ -45,6 +39,12 @@ prompts: {%- endif %} Line numbers returned by Serena's tools are 0-based! + + Serena executes tool calls one at a time internally, even when you request several at once. So whenever you have + independent operations to perform, you can safely issue them as parallel tool calls in a single turn: they are applied + in the order issued and cannot race with or corrupt one another, and batching them this way saves round-trips. Every + separate round-trip re-sends the whole growing context, so maximizing parallel tool calls is the single biggest lever + on cost — err on the side of batching. Only call tools sequentially when one genuinely depends on the result of another. The context and modes of operation are described below. These determine how to interact with your user and which kinds of interactions are expected of you. From 99ad6eaaeeb4c245dd7590f1a9262e3a470e1d7f Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 29 Jun 2026 15:56:21 +0200 Subject: [PATCH 065/170] Fix JetBrainsPluginClient._make_request incorrectly passing None as data Fixes #1615 --- src/serena/jetbrains/jetbrains_plugin_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index b03dacfe..22fa9c27 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -311,8 +311,8 @@ class JetBrainsPluginClient(ToStringMixin): if method.upper() == "GET": response = self._session.get(url, timeout=self._timeout) elif method.upper() == "POST": - json_data = json.dumps(data) if data else None - response = self._session.post(url, data=json_data, timeout=self._timeout) + data_dict = data if data is not None else {} + response = self._session.post(url, data=json.dumps(data_dict), timeout=self._timeout) else: raise ValueError(f"Unsupported HTTP method: {method}") From 3d9b953ac6e0c07d1134ffa86702bf960eba0ffd Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Mon, 29 Jun 2026 12:10:39 +0200 Subject: [PATCH 066/170] Add mode `benchmark` Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../resources/config/modes/benchmark.yml | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/serena/resources/config/modes/benchmark.yml diff --git a/src/serena/resources/config/modes/benchmark.yml b/src/serena/resources/config/modes/benchmark.yml new file mode 100644 index 00000000..636f5f4b --- /dev/null +++ b/src/serena/resources/config/modes/benchmark.yml @@ -0,0 +1,20 @@ +description: Focus on completely finishing a task without interaction +prompt: | + You are operating in one-shot mode. Your goal is to complete the entire task autonomously without further user interaction. + You should assume auto-approval for all tools and continue working until the task is completely finished. + + If the task is planning, your final result should be a comprehensive plan. If the task is coding, your final result + should be working code with all requirements fulfilled. Try to understand what the user asks you to do + and to assume as little as possible. + + Only abort the task if absolutely necessary, such as when critical information is missing that cannot be inferred + from the codebase. + + The task is provided up front; begin work immediately and see it through to completion without pausing for user input. +excluded_tools: + - write_memory + - read_memory + - edit_memory + - delete_memory + - rename_memory + - onboarding From 673f657dccdbe4786b3f8233ee7447739c78b994 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 29 Jun 2026 16:06:11 +0200 Subject: [PATCH 067/170] Apply formatter, fix spelling --- src/serena/memories/memory_manager.py | 1 - test/serena/util/test_yaml.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/serena/memories/memory_manager.py b/src/serena/memories/memory_manager.py index 60e5e7d8..370e3572 100644 --- a/src/serena/memories/memory_manager.py +++ b/src/serena/memories/memory_manager.py @@ -2,7 +2,6 @@ import logging import os import re import shutil -import sys from collections.abc import Iterator, Sequence from pathlib import Path from typing import Literal diff --git a/test/serena/util/test_yaml.py b/test/serena/util/test_yaml.py index a64125ba..724438c5 100644 --- a/test/serena/util/test_yaml.py +++ b/test/serena/util/test_yaml.py @@ -3,7 +3,7 @@ Regression: ``~/.serena/serena_config.yml`` was corrupted in the field when a non-atomic truncate-and-write saved a SHORTER value over a longer existing file (or two Serena processes saved concurrently), leaving a stale tail (e.g. a dangling ``ena`` line) that made the YAML -unparseable, which wedged every later load. ``save_yaml`` now writes to a temp file and +unparsable, which wedged every later load. ``save_yaml`` now writes to a temp file and ``os.replace``s it onto the target, so each write is all-or-nothing. """ From 9c309ac8daa8b4ebc1b7c4c6ab8c91ebfcf8576a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 26 Jun 2026 12:04:19 +0200 Subject: [PATCH 068/170] Update tool descriptions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/serena/tools/file_tools.py | 20 ++++++++------------ src/serena/tools/jetbrains_tools.py | 21 +++++++++++---------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index dd61903a..1cbf0c21 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -34,7 +34,7 @@ class ReadFileTool(Tool): Reads the given file or a chunk of it. :param relative_path: the relative path to the file to read - :param start_line: the 0-based index of the first line to be retrieved. + :param start_line: the 0-based index of the first line to be retrieved, negative values count from the end of the file. :param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file. :param max_answer_chars: if the file (chunk) is longer than this number of characters, no content will be returned. Don't adjust unless there is really no other way to get the content @@ -61,7 +61,7 @@ class CreateTextFileTool(EditingToolWithDiagnostics): def apply(self, relative_path: str, content: str) -> str: """ - Write a new file or overwrite an existing file. + Write a new file or overwrite an existing file with the given content. :param relative_path: the relative path to the file to create :param content: the (appropriately encoded) content to write to the file @@ -182,16 +182,12 @@ class ReplaceContentTool(EditingToolWithDiagnostics): r""" Replaces one or more occurrences of a given pattern in a file with new content. - This is the preferred way to replace content in a file whenever the symbol-level - tools are not appropriate. - - VERY IMPORTANT: The "regex" mode allows very large sections of code to be replaced without fully quoting them! - Use a regex of the form "beginning.*?end-of-text-to-be-replaced" to be faster and more economical! - ALWAYS try to use wildcards to avoid specifying the exact content to be replaced, - especially if it spans several lines. Note that you cannot make mistakes, because if the regex should match - multiple occurrences while you disabled `allow_multiple_occurrences`, an error will be returned, and you can retry - with a revised regex. - Therefore, using regex mode with suitable wildcards is usually the best choice! + VERY IMPORTANT: The "regex" mode allows very large sections of code to be replaced WITHOUT + quoting them fully: use a needle of the form "beginning.*?end-of-text-to-be-replaced" with + wildcards instead of pasting the exact original text — shorter, cheaper, and you cannot make + mistakes, because an ambiguous match returns an error you can refine, so wildcards are safe. + Prefer regex mode with suitable wildcards for long multi-line replacements; use the + symbol-level editors when replacing a whole method/class. :param relative_path: the relative path to the file :param needle: the string or regex pattern to search for. diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index c29d58ce..a46736ba 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -40,6 +40,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): Specify `depth > 0` to retrieve children (e.g., methods of a class). Important: through `search_deps=True` dependencies can be searched, which should be preferred to web search or other less sophisticated approaches to analyzing dependencies. + You will always receive at least quick info on the symbol (even if `include_info=False`). A name path is a path in the symbol tree *within a source file*. For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`. @@ -52,6 +53,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): * a relative path like "class/method", which will match any symbol with that name path suffix * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file. Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]". + In any path component, using `*` will match any sequence of characters (excluding /), e.g. "Class/*substring*" matches a member substring. :param name_path_pattern: the name path matching pattern (see above) :param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children; @@ -60,7 +62,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): :param relative_path: Optional. Restrict search to this file or directory. If not specified, searches entire codebase. Note: for external dependencies, this must be an identifier starting with `<ext` that you have received earlier (don't try to guess!). - :param include_body: If True, include the symbol's source code. Use judiciously. + :param include_body: If True, include the symbol's full source code. :param include_info: whether to include additional info (hover-like, typically including docstring and signature), about the symbol. Default False; info is never included for child symbols or if include_body is True. @@ -265,8 +267,9 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark max_answer_chars: int = -1, ) -> str: """ - Finds symbols that reference the symbol at the specified symbol, i.e. returns symbols whose definitions (e.g. a function body) contain a reference to the given symbol. - The result will contain metadata about the referencing symbols. + Finds all symbols that reference the given symbol — its callers / usages / dependents, i.e. the + symbols whose own definition (e.g. a method body) contains a reference to it. For each, returns its + name path, file, and the surrounding line of code. :param name_path: name path of the symbol for which to find references :param relative_path: the relative path to the file containing the symbol (must be a file, not a directory) @@ -328,11 +331,9 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp include_file_documentation: bool = False, ) -> str: """ - Gets an overview of the top-level symbols in the given file. - Calling this is often a good idea before more targeted reading, searching or editing operations on the code symbols. - Before requesting a symbol overview, it is usually a good idea to narrow down the scope of the overview - by first understanding the basic directory structure of the repository that you can get from memories - or by using the `list_dir` and `find_file` tools (or similar). + Gets an overview of the top-level symbols defined in the given file (classes, methods, fields) — its + STRUCTURE, without their bodies. This is the cheap, structure-first way to learn what a file + contains: it costs far less context than reading the whole file. :param relative_path: the relative path to the file to get the overview of :param depth: depth up to which descendants shall be retrieved. @@ -590,8 +591,8 @@ class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): repl_key: str = "default", ) -> str: """ - Debug code interactively by evaluating Groovy expressions in a persistent REPL. - Important: Debugging should only be applied if the user has requested it! + Debug code by evaluating Groovy/Java expressions in a persistent REPL attached to the IDE's + debugger (run configs, breakpoints, stepping, inspection of live state). Use the `serena_info` tool with topic `jet_brains_debug_repl` for usage information. From 103a17072e9b915c9c9980f946902be856695978 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 29 Jun 2026 17:24:28 +0200 Subject: [PATCH 069/170] Update change log --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b914c21c..c12bb3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Status of the `main` branch. Changes prior to the next official version change w * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically upon project activation + - Fix: `jet_brains_list_inspections` failed when only default parameters were used #1615 * Dashboard: - Make list of trusted hosts configurable, fixing host validation introduced in v1.5.2 allowing only From 6e518b5f7cb80d06ec3bdf899b4dc825a770929c Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 1 Jul 2026 13:50:53 +0200 Subject: [PATCH 070/170] Refactor search in files/iterative file processing * Introduce abstractions: FileCollection, FileProxy * Unify handling of pattern search in project files (source code vs. all files) This prepares for processing of external files, which can implement FileProxy differently. --- src/serena/project.py | 28 +++++-- src/serena/tools/file_tools.py | 45 +++------- src/serena/util/file_proxy.py | 83 +++++++++++++++++++ src/serena/util/text_utils.py | 69 ++++++--------- test/serena/test_text_utils.py | 37 ++++++--- test/solidlsp/clojure/test_clojure_basic.py | 4 +- .../elixir/test_elixir_integration.py | 8 +- test/solidlsp/python/test_python_basic.py | 12 +-- test/solidlsp/swift/test_swift_basic.py | 6 +- 9 files changed, 179 insertions(+), 113 deletions(-) create mode 100644 src/serena/util/file_proxy.py diff --git a/src/serena/project.py b/src/serena/project.py index 1ddc53ac..e40c2dab 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -14,7 +14,8 @@ from serena.config.serena_config import ( ) from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.memories.memory_manager import MemoryManager -from serena.util.file_system import GitignoreParser, match_path +from serena.util.file_proxy import FileCollection +from serena.util.file_system import GitignoreParser, match_path, scan_directory from serena.util.text_utils import MatchedConsecutiveLines, search_files from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @@ -344,7 +345,7 @@ class Project(ToStringMixin): ) return rel_file_paths - def search_source_files_for_pattern( + def search_project_files_for_pattern( self, pattern: str, relative_path: str = "", @@ -353,6 +354,7 @@ class Project(ToStringMixin): paths_include_glob: str | None = None, paths_exclude_glob: str | None = None, multiline: bool = True, + code_files_only: bool = True, ) -> list[MatchedConsecutiveLines]: """ Search for a pattern across all (non-ignored) source files @@ -366,12 +368,26 @@ class Project(ToStringMixin): :param multiline: Whether to compile the regex with the DOTALL flag (``.`` matches newlines). :return: List of matched consecutive lines with context """ - relative_file_paths = self.gather_source_files(relative_path=relative_path) + if code_files_only: + relative_file_paths = self.gather_source_files(relative_path=relative_path) + file_collection = FileCollection.from_local_project_paths(relative_file_paths, self) + else: + abs_path = os.path.join(self.project_root, relative_path) + if os.path.isfile(abs_path): + rel_paths_to_search = [relative_path] + else: + _dirs, rel_paths_to_search = scan_directory( + path=abs_path, + recursive=True, + is_ignored_dir=self.is_ignored_path, + is_ignored_file=self.is_ignored_path, + relative_to=self.project_root, + ) + file_collection = FileCollection.from_local_project_paths(rel_paths_to_search, self) + return search_files( - relative_file_paths, + file_collection, pattern, - root_path=self.project_root, - file_reader=self.read_file, context_lines_before=context_lines_before, context_lines_after=context_lines_after, paths_include_glob=paths_include_glob, diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index dd61903a..8a3a3e5e 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -20,7 +20,6 @@ from serena.util.text_utils import ( ReplacementOccurrence, expand_braces, glob_match, - search_files, ) @@ -599,40 +598,16 @@ class SearchForPatternTool(Tool): if not os.path.exists(abs_path): raise FileNotFoundError(f"Relative path {relative_path} does not exist.") - if restrict_search_to_code_files: - matches = self.project.search_source_files_for_pattern( - pattern=substring_pattern, - relative_path=relative_path, - context_lines_before=context_lines_before, - context_lines_after=context_lines_after, - paths_include_glob=paths_include_glob.strip(), - paths_exclude_glob=paths_exclude_glob.strip(), - multiline=multiline, - ) - else: - if os.path.isfile(abs_path): - rel_paths_to_search = [relative_path] - else: - _dirs, rel_paths_to_search = scan_directory( - path=abs_path, - recursive=True, - is_ignored_dir=self.project.is_ignored_path, - is_ignored_file=self.project.is_ignored_path, - relative_to=self.get_project_root(), - ) - # TODO (maybe): not super efficient to walk through the files again and filter if glob patterns are provided - # but it probably never matters and this version required no further refactoring - matches = search_files( - rel_paths_to_search, - substring_pattern, - context_lines_before=context_lines_before, - context_lines_after=context_lines_after, - file_reader=self.project.read_file, - root_path=self.get_project_root(), - paths_include_glob=paths_include_glob, - paths_exclude_glob=paths_exclude_glob, - multiline=multiline, - ) + matches = self.project.search_project_files_for_pattern( + pattern=substring_pattern, + relative_path=relative_path, + context_lines_before=context_lines_before, + context_lines_after=context_lines_after, + paths_include_glob=paths_include_glob.strip(), + paths_exclude_glob=paths_exclude_glob.strip(), + multiline=multiline, + code_files_only=restrict_search_to_code_files, + ) # group matches by file file_to_matches: dict[str, list[str]] = defaultdict(list) diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py new file mode 100644 index 00000000..13fc070a --- /dev/null +++ b/src/serena/util/file_proxy.py @@ -0,0 +1,83 @@ +import logging +import os +from abc import ABC, abstractmethod +from collections.abc import Iterator +from typing import TYPE_CHECKING, Self + +if TYPE_CHECKING: + from serena.project import Project + +log = logging.getLogger(__name__) + + +class FileProxy(ABC): + @abstractmethod + def get_contents(self) -> str: + """:return: the contents of the file as a string.""" + + @abstractmethod + def get_relative_path(self) -> str: + """:return: the relative path reported by Serena (actual relative path or encoded external path)""" + + +class LocalProjectFileProxy(FileProxy): + def __init__(self, relative_path: str, project: "Project"): + self._relative_path = relative_path + self._project = project + + def get_contents(self) -> str: + abs_path = os.path.join(self._project.project_root, self._relative_path) + with open(abs_path, encoding=self._project.project_config.encoding) as f: + return f.read() + + def get_relative_path(self) -> str: + return self._relative_path + + +class FileCollection: + def __init__(self, file_proxies: list[FileProxy]): + self._file_proxies = file_proxies + + def __len__(self) -> int: + return len(self._file_proxies) + + def __iter__(self) -> Iterator[FileProxy]: + return iter(self._file_proxies) + + @classmethod + def from_local_project_paths(cls, relative_paths: list[str], project: "Project") -> Self: + return cls([LocalProjectFileProxy(path, project) for path in relative_paths]) + + def filter_glob(self, paths_include_glob: str | None = None, paths_exclude_glob: str | None = None) -> "FileCollection": + """ + Filters the collection based on the given patterns. + Note: Filtering is applied only to local project files. Other files are always retained. + + :param paths_include_glob: optional glob pattern to include files from the list + :param paths_exclude_glob: optional glob pattern to exclude files from the list + :return: the filtered collection + """ + from serena.util.text_utils import GlobMatcher + + if paths_include_glob is None and paths_exclude_glob is None: + return self + + include_glob_matcher = GlobMatcher(paths_include_glob) if paths_include_glob else None + exclude_glob_matcher = GlobMatcher(paths_exclude_glob) if paths_exclude_glob else None + + filtered_files = [] + for f in self._file_proxies: + if isinstance(f, LocalProjectFileProxy): + local_file: LocalProjectFileProxy = f + path = local_file.get_relative_path() + if include_glob_matcher: + if not include_glob_matcher.matches(path): + log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}") + continue + if exclude_glob_matcher: + if exclude_glob_matcher.matches(path): + log.debug(f"Skipping {path}: matches exclude pattern {paths_exclude_glob}") + continue + filtered_files.append(f) + + return FileCollection(filtered_files) diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index aff92fdb..b8020e12 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -1,7 +1,6 @@ import fnmatch import hashlib import logging -import os import re from collections.abc import Callable from dataclasses import dataclass, field @@ -11,7 +10,7 @@ from typing import Any, Literal, Self from bs4 import BeautifulSoup from joblib import Parallel, delayed -from serena.constants import DEFAULT_SOURCE_FILE_ENCODING +from serena.util.file_proxy import FileCollection, FileProxy from solidlsp.ls_utils import TextUtils log = logging.getLogger(__name__) @@ -237,12 +236,6 @@ def search_text( return matches -def default_file_reader(file_path: str) -> str: - """Reads using the default encoding.""" - with open(file_path, encoding=DEFAULT_SOURCE_FILE_ENCODING) as f: - return f.read() - - def expand_braces(pattern: str) -> list[str]: """ Expands brace patterns in a glob string. @@ -319,11 +312,17 @@ def glob_match(pattern: str, path: str) -> bool: return fnmatch.fnmatch(path, pattern) +class GlobMatcher: + def __init__(self, expr: str): + self._patterns = expand_braces(expr) + + def matches(self, path: str) -> bool: + return any(glob_match(p, path) for p in self._patterns) + + def search_files( - relative_file_paths: list[str], + file_collection: FileCollection, pattern: str, - root_path: str = "", - file_reader: Callable[[str], str] = default_file_reader, context_lines_before: int = 0, context_lines_after: int = 0, paths_include_glob: str | None = None, @@ -333,11 +332,8 @@ def search_files( """ Search for a pattern in a list of files. - :param relative_file_paths: list of relative file paths in which to search + :param file_collection: the collection of files to search (will be optionally filtered by glob patterns) :param pattern: pattern to search for - :param root_path: root path to resolve relative paths against (by default, current working directory). - :param file_reader: function to read a file, by default will just use os.open. - All files that can't be read by it will be skipped. :param context_lines_before: number of context lines to include before matches :param context_lines_after: number of context lines to include after matches :param paths_include_glob: optional glob pattern to include files from the list @@ -345,53 +341,36 @@ def search_files( :param multiline: whether to apply multi-line matching, enabling the flags re.DOTALL and re.MULTILINE (default: True) :return: list of MatchedConsecutiveLines objects """ - # Pre-filter paths (done sequentially to avoid overhead) - # Use proper glob matching instead of gitignore patterns - include_patterns = expand_braces(paths_include_glob) if paths_include_glob else None - exclude_patterns = expand_braces(paths_exclude_glob) if paths_exclude_glob else None + # apply glob filter + file_collection = file_collection.filter_glob(paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob) + log.info(f"Processing {len(file_collection)} files.") - filtered_paths = [] - for path in relative_file_paths: - if include_patterns: - if not any(glob_match(p, path) for p in include_patterns): - log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}") - continue - - if exclude_patterns: - if any(glob_match(p, path) for p in exclude_patterns): - log.debug(f"Skipping {path}: matches exclude pattern {paths_exclude_glob}") - continue - - filtered_paths.append(path) - - log.info(f"Processing {len(filtered_paths)} files.") - - def process_single_file(path: str) -> dict[str, Any]: + def process_single_file(file_proxy: FileProxy) -> dict[str, Any]: """Process a single file - this function will be parallelized.""" + relative_path = file_proxy.get_relative_path() try: - abs_path = os.path.join(root_path, path) - file_content = file_reader(abs_path) + file_content = file_proxy.get_contents() search_results = search_text( pattern, content=file_content, - source_file_path=path, + source_file_path=relative_path, allow_multiline_match=True, context_lines_before=context_lines_before, context_lines_after=context_lines_after, multiline=multiline, ) if len(search_results) > 0: - log.debug(f"Found {len(search_results)} matches in {path}") - return {"path": path, "results": search_results, "error": None} + log.debug(f"Found {len(search_results)} matches in {relative_path}") + return {"path": relative_path, "results": search_results, "error": None} except Exception as e: - log.debug(f"Error processing {path}: {e}") - return {"path": path, "results": [], "error": str(e)} + log.debug(f"Error processing {relative_path}: {e}") + return {"path": relative_path, "results": [], "error": str(e)} # Execute in parallel using joblib results = Parallel( n_jobs=-1, backend="threading", - )(delayed(process_single_file)(path) for path in filtered_paths) + )(delayed(process_single_file)(file_proxy) for file_proxy in file_collection) # Collect results and errors matches = [] @@ -406,7 +385,7 @@ def search_files( if skipped_file_error_tuples: log.debug(f"Failed to read {len(skipped_file_error_tuples)} files: {skipped_file_error_tuples}") - log.info(f"Found {len(matches)} total matches across {len(filtered_paths)} files") + log.info(f"Found {len(matches)} total matches across {len(file_collection)} files") return matches diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index e6cd4539..408aadac 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -1,7 +1,9 @@ import re +from collections.abc import Callable import pytest +from serena.util.file_proxy import FileCollection, FileProxy from serena.util.text_utils import LineType, MultiFileContentReplacer, search_files, search_text @@ -221,6 +223,23 @@ def mock_reader_always_match(file_path: str) -> str: return "This line contains a match." +class MockFileProxy(FileProxy): + def __init__(self, relative_path: str, mock_reader: Callable[[str], str] = mock_reader_always_match): + self.relative_path = relative_path + self.mock_reader = mock_reader + + def get_contents(self) -> str: + return self.mock_reader(self.relative_path) + + def get_relative_path(self) -> str: + return self.relative_path + + +class MockFileCollection(FileCollection): + def __init__(self, file_paths, mock_reader: Callable[[str], str] = mock_reader_always_match): + super().__init__([MockFileProxy(path, mock_reader) for path in file_paths]) + + class TestSearchFiles: @pytest.mark.parametrize( "file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description", @@ -254,9 +273,8 @@ class TestSearchFiles: Test the include/exclude glob filtering logic in search_files using PathSpec patterns. """ results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths), pattern=pattern, - file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, context_lines_before=0, # No context needed for this test focus @@ -333,9 +351,8 @@ class TestSearchFiles: Test glob patterns that were problematic with the previous gitignore-based implementation. """ results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths), pattern=pattern, - file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, context_lines_before=0, @@ -414,9 +431,8 @@ class TestSearchFiles: ): """Test search_files with glob patterns containing brace expansions.""" results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths), pattern=pattern, - file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, ) @@ -429,9 +445,8 @@ class TestSearchFiles: file_paths = ["a.py", "b.txt"] pattern = "non_existent_pattern_in_mock_content" # This won't match mock_reader_always_match content results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths), pattern=pattern, - file_reader=mock_reader_always_match, # Content is "This line contains a match." paths_include_glob=None, # Both files would pass filters paths_exclude_glob=None, ) @@ -454,9 +469,8 @@ class TestSearchFiles: pattern = r"value=(\d+)" results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths, specific_mock_reader), pattern=pattern, - file_reader=specific_mock_reader, paths_include_glob="*.py", # Only include .py files paths_exclude_glob="b.*", # Exclude files starting with b ) @@ -484,9 +498,8 @@ class TestSearchFiles: pattern = "MATCH HERE" results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths, context_mock_reader), pattern=pattern, - file_reader=context_mock_reader, paths_include_glob="*.txt", # Only include .txt files paths_exclude_glob=None, context_lines_before=1, diff --git a/test/solidlsp/clojure/test_clojure_basic.py b/test/solidlsp/clojure/test_clojure_basic.py index c42067ac..3dd06d8e 100644 --- a/test/solidlsp/clojure/test_clojure_basic.py +++ b/test/solidlsp/clojure/test_clojure_basic.py @@ -211,7 +211,7 @@ class TestProjectBasics: @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) def test_search_files_for_pattern(self, project: Project) -> None: - result = project.search_source_files_for_pattern("defn.*greet") + result = project.search_project_files_for_pattern("defn.*greet") assert result is not None, "Pattern search should return results" assert len(result) > 0, "Should find at least one match for 'defn.*greet'" @@ -219,7 +219,7 @@ class TestProjectBasics: core_matches = [match for match in result if match.source_file_path and "core.clj" in match.source_file_path] assert len(core_matches) > 0, "Should find greet function in core.clj" - result = project.search_source_files_for_pattern(":require") + result = project.search_project_files_for_pattern(":require") assert result is not None, "Should find require statements" utils_matches = [match for match in result if match.source_file_path and "utils.clj" in match.source_file_path] diff --git a/test/solidlsp/elixir/test_elixir_integration.py b/test/solidlsp/elixir/test_elixir_integration.py index 678ce762..1658993f 100644 --- a/test/solidlsp/elixir/test_elixir_integration.py +++ b/test/solidlsp/elixir/test_elixir_integration.py @@ -115,7 +115,7 @@ class TestElixirProject: """Test comprehensive symbol search across the entire project.""" # Search for all function definitions function_pattern = r"def\s+\w+\s*[\(\s]" - function_matches = project.search_source_files_for_pattern(function_pattern) + function_matches = project.search_project_files_for_pattern(function_pattern) # Should find functions across multiple files if function_matches: @@ -131,7 +131,7 @@ class TestElixirProject: # Search for struct definitions struct_pattern = r"defstruct\s+\[" - struct_matches = project.search_source_files_for_pattern(struct_pattern) + struct_matches = project.search_project_files_for_pattern(struct_pattern) if struct_matches: # Should find structs primarily in models.ex @@ -143,7 +143,7 @@ class TestElixirProject: """Test that the language server understands Elixir protocols and implementations.""" # Search for protocol definitions protocol_pattern = r"defprotocol\s+\w+" - protocol_matches = project.search_source_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") + protocol_matches = project.search_project_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") if protocol_matches: # Should find the Serializable protocol @@ -152,7 +152,7 @@ class TestElixirProject: # Search for protocol implementations impl_pattern = r"defimpl\s+\w+" - impl_matches = project.search_source_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") + impl_matches = project.search_project_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") if impl_matches: # Should find multiple implementations diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py index f3d46f0f..283e3e56 100644 --- a/test/solidlsp/python/test_python_basic.py +++ b/test/solidlsp/python/test_python_basic.py @@ -214,28 +214,28 @@ class TestProjectBasics: """Test search_files_for_pattern with various patterns and glob filters.""" # Test 1: Search for class definitions across all files class_pattern = r"class\s+\w+\s*(?:\([^{]*\)|:)" - matches = project.search_source_files_for_pattern(class_pattern) + matches = project.search_project_files_for_pattern(class_pattern) assert len(matches) > 0 # Should find multiple classes like User, Item, BaseModel, etc. assert len(matches) >= 5 # Test 2: Search for specific class with include glob user_class_pattern = r"class\s+User\s*(?:\([^{]*\)|:)" - matches = project.search_source_files_for_pattern(user_class_pattern, paths_include_glob="**/models.py") + matches = project.search_project_files_for_pattern(user_class_pattern, paths_include_glob="**/models.py") assert len(matches) == 1 # Should only find User class in models.py assert matches[0].source_file_path is not None assert "models.py" in matches[0].source_file_path # Test 3: Search for method definitions with exclude glob method_pattern = r"def\s+\w+\s*\([^)]*\):" - matches = project.search_source_files_for_pattern(method_pattern, paths_exclude_glob="**/models.py") + matches = project.search_project_files_for_pattern(method_pattern, paths_exclude_glob="**/models.py") assert len(matches) > 0 # Should find methods in services.py but not in models.py assert all(match.source_file_path is not None and "models.py" not in match.source_file_path for match in matches) # Test 4: Search for specific method with both include and exclude globs create_user_pattern = r"def\s+create_user\s*\([^)]*\)(?:\s*->[^:]+)?:" - matches = project.search_source_files_for_pattern( + matches = project.search_project_files_for_pattern( create_user_pattern, paths_include_glob="**/*.py", paths_exclude_glob="**/models.py" ) assert len(matches) == 1 # Should only find create_user in services.py @@ -244,7 +244,7 @@ class TestProjectBasics: # Test 5: Search for a pattern that should appear in multiple files init_pattern = r"def\s+__init__\s*\([^)]*\):" - matches = project.search_source_files_for_pattern(init_pattern) + matches = project.search_project_files_for_pattern(init_pattern) assert len(matches) > 1 # Should find __init__ in multiple classes # Should find __init__ in both models.py and services.py assert any(match.source_file_path is not None and "models.py" in match.source_file_path for match in matches) @@ -252,7 +252,7 @@ class TestProjectBasics: # Test 6: Search with a pattern that should have no matches no_match_pattern = r"def\s+this_method_does_not_exist\s*\([^)]*\):" - matches = project.search_source_files_for_pattern(no_match_pattern) + matches = project.search_project_files_for_pattern(no_match_pattern) assert len(matches) == 0 @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index a2282fb2..f4395f4c 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -198,7 +198,7 @@ class TestSwiftProjectBasics: # Scenario 3: Search for struct definitions struct_pattern = r"struct\s+\w+" - matches = project.search_source_files_for_pattern(struct_pattern) + matches = project.search_project_files_for_pattern(struct_pattern) assert len(matches) > 0, "Should find struct definitions" # Should find User struct user_matches = [m for m in matches if "User" in str(m)] @@ -206,7 +206,7 @@ class TestSwiftProjectBasics: # Scenario 4: Search for class definitions class_pattern = r"class\s+\w+" - matches = project.search_source_files_for_pattern(class_pattern) + matches = project.search_project_files_for_pattern(class_pattern) assert len(matches) > 0, "Should find class definitions" # Should find Calculator and Circle classes calculator_matches = [m for m in matches if "Calculator" in str(m)] @@ -216,7 +216,7 @@ class TestSwiftProjectBasics: # Scenario 5: Search for enum definitions enum_pattern = r"enum\s+\w+" - matches = project.search_source_files_for_pattern(enum_pattern) + matches = project.search_project_files_for_pattern(enum_pattern) assert len(matches) > 0, "Should find enum definitions" # Should find Status enum status_matches = [m for m in matches if "Status" in str(m)] From f39906728da524fd0d1669466ad1c0de9685682c Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 1 Jul 2026 13:56:41 +0200 Subject: [PATCH 071/170] search_text: Remove obsolete option allow_multiline_match The obsolete option was only used in tests, where adjusting the `multiline` parameter restored the tested functionality. --- src/serena/util/text_utils.py | 76 +++++++++++----------------------- test/serena/test_text_utils.py | 17 ++++---- 2 files changed, 32 insertions(+), 61 deletions(-) diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index b8020e12..67110f70 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -141,7 +141,6 @@ def search_text( pattern: str, content: str | None = None, source_file_path: str | None = None, - allow_multiline_match: bool = False, context_lines_before: int = 0, context_lines_after: int = 0, is_glob: bool = False, @@ -154,8 +153,6 @@ def search_text( :param content: The text content to search. May be None if source_file_path is provided. :param source_file_path: Optional path to the source file. If content is None, this has to be passed and the file will be read. - :param allow_multiline_match: Whether to search across multiple lines. Currently, the default - option (False) is very inefficient, so it is recommended to set this to True. :param context_lines_before: Number of context lines to include before matches :param context_lines_after: Number of context lines to include after matches :param is_glob: If True, pattern is treated as a glob-like pattern (e.g., "*.py", "test_??.py") @@ -178,60 +175,36 @@ def search_text( # Convert pattern to a compiled regex if it's a string if is_glob: pattern = glob_to_regex(pattern) - if allow_multiline_match: - # For multiline matches, optionally use DOTALL so '.' matches newlines - flags = (re.MULTILINE | re.DOTALL) if multiline else 0 - compiled_pattern = re.compile(pattern, flags) - # Search across the entire content as a single string - for match in compiled_pattern.finditer(content): - start_pos = match.start() - end_pos = match.end() - # Find the line numbers for the start and end positions - start_line_num = content[:start_pos].count("\n") - end_line_num = content[:end_pos].count("\n") + # For multiline matches, optionally use DOTALL so '.' matches newlines + flags = (re.MULTILINE | re.DOTALL) if multiline else 0 + compiled_pattern = re.compile(pattern, flags) + # Search across the entire content as a single string + for match in compiled_pattern.finditer(content): + start_pos = match.start() + end_pos = match.end() - # Calculate the range of lines to include in the context - context_start = max(0, start_line_num - context_lines_before) - context_end = min(total_lines - 1, end_line_num + context_lines_after) + # Find the line numbers for the start and end positions + start_line_num = content[:start_pos].count("\n") + end_line_num = content[:end_pos].count("\n") - # Create TextLine objects for the context - context_lines = [] - for line_num in range(context_start, context_end + 1): - if context_start <= line_num < start_line_num: - match_type = LineType.BEFORE_MATCH - elif end_line_num < line_num <= context_end: - match_type = LineType.AFTER_MATCH - else: - match_type = LineType.MATCH + # Calculate the range of lines to include in the context + context_start = max(0, start_line_num - context_lines_before) + context_end = min(total_lines - 1, end_line_num + context_lines_after) - context_lines.append(TextLine(line_number=line_num, line_content=lines[line_num], match_type=match_type)) + # Create TextLine objects for the context + context_lines = [] + for line_num in range(context_start, context_end + 1): + if context_start <= line_num < start_line_num: + match_type = LineType.BEFORE_MATCH + elif end_line_num < line_num <= context_end: + match_type = LineType.AFTER_MATCH + else: + match_type = LineType.MATCH - matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) - else: - # TODO: extremely inefficient! Since we currently don't use this option in SerenaAgent or LanguageServer, - # it is not urgent to fix, but should be either improved or the option should be removed. - # Search line by line, normal compile without DOTALL - compiled_pattern = re.compile(pattern) - for i, line in enumerate(lines): - if compiled_pattern.search(line): - # Calculate the range of lines to include in the context - context_start = max(0, i - context_lines_before) - context_end = min(total_lines - 1, i + context_lines_after) + context_lines.append(TextLine(line_number=line_num, line_content=lines[line_num], match_type=match_type)) - # Create TextLine objects for the context - context_lines = [] - for j in range(context_start, context_end + 1): - if j < i: - match_type = LineType.BEFORE_MATCH - elif j > i: - match_type = LineType.AFTER_MATCH - else: - match_type = LineType.MATCH - - context_lines.append(TextLine(line_number=j, line_content=lines[j], match_type=match_type)) - - matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) + matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) return matches @@ -354,7 +327,6 @@ def search_files( pattern, content=file_content, source_file_path=relative_path, - allow_multiline_match=True, context_lines_before=context_lines_before, context_lines_after=context_lines_after, multiline=multiline, diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 408aadac..bdfa6e76 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -1,4 +1,3 @@ -import re from collections.abc import Callable import pytest @@ -49,7 +48,7 @@ class TestSearchText: assert "def process" in matches[1].lines[0].line_content assert "def filter" in matches[2].lines[0].line_content - def test_search_text_with_compiled_regex(self): + def test_search_text_with_regex_pattern2(self): """Test searching with a pre-compiled regex pattern.""" content = """ import os @@ -66,8 +65,8 @@ class TestSearchText: """ # Search for variable assignments with a compiled regex - pattern = re.compile(r"^\s*[A-Z_]+ = .+$") - matches = search_text(pattern, content=content) + pattern = r"^\s*[A-Z_]+ = .*?$" + matches = search_text(pattern, content=content, multiline=True) assert len(matches) == 2 assert "DEBUG = True" in matches[0].lines[0].line_content @@ -117,7 +116,7 @@ class TestSearchText: # Search for a pattern that spans multiple lines (if-else block) pattern = r"if.*?else.*?return" - matches = search_text(pattern, content=content, allow_multiline_match=True) + matches = search_text(pattern, content=content) assert len(matches) == 1 multiline_match = matches[0] @@ -145,7 +144,7 @@ class TestSearchText: """ # Search with a glob pattern for all user methods - matches = search_text("*_user*", content=content, is_glob=True) + matches = search_text("*_user*", content=content, is_glob=True, multiline=False) assert len(matches) == 3 assert "get_user" in matches[0].lines[0].line_content @@ -170,7 +169,7 @@ class TestSearchText: """ # Search with a simplified glob pattern to find all isinstance occurrences - matches = search_text("*isinstance*", content=content, is_glob=True) + matches = search_text("*isinstance*", content=content, is_glob=True, multiline=False) # Should match lines with isinstance(item, dict) and isinstance(item, list) assert len(matches) >= 2 @@ -194,11 +193,11 @@ class TestSearchText: print("value{bar}") """ - matches_square = search_text(r"*\[42\]*", content=content, is_glob=True) + matches_square = search_text(r"*\[42\]*", content=content, is_glob=True, multiline=False) assert len(matches_square) == 1 assert "[42]" in matches_square[0].lines[0].line_content - matches_curly = search_text("*{bar}*", content=content, is_glob=True) + matches_curly = search_text("*{bar}*", content=content, is_glob=True, multiline=False) assert len(matches_curly) == 1 assert "{bar}" in matches_curly[0].lines[0].line_content From 423c04101d99c5ec9f74135d44ffdc22c82b5a4e Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 1 Jul 2026 14:15:16 +0200 Subject: [PATCH 072/170] Make Project aware of the language backend (as it can affect its behaviour) --- src/serena/agent.py | 10 ++++------ src/serena/config/serena_config.py | 11 +++++++++++ src/serena/project.py | 11 +++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index d5b69269..953e4241 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -640,12 +640,10 @@ class SerenaAgent: # determine the effective language backend for this session. # If a startup project is provided and has a per-project override, use it; otherwise use the global config. # Since we don't want to change the toolset after startup, the language backend cannot be changed within a running Serena session - self._language_backend = self.serena_config.language_backend - if registered_project_to_activate is not None and registered_project_to_activate.project_config.language_backend is not None: - self._language_backend = registered_project_to_activate.project_config.language_backend - log.info(f"Using language backend as configured in project.yml: {self._language_backend.name}") - else: - log.info(f"Using language backend from global configuration: {self._language_backend.name}") + self._language_backend = self.serena_config.determine_language_backend( + project_config=registered_project_to_activate.project_config if registered_project_to_activate is not None else None, + log_choice=True, + ) # create the tool names mapping for prompts self._prompt_tool_names_mapping = self._create_prompt_tool_names_mapping(self._language_backend) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 0b8fc673..2b580082 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -1297,3 +1297,14 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): if glob_match(pattern, project_root_str): return True return False + + def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False): + language_backend = self.language_backend + if project_config and project_config.language_backend is not None: + language_backend = project_config.language_backend + if log_choice: + log.info(f"Using language backend as configured in project: {language_backend.name}") + else: + if log_choice: + log.info(f"Using language backend from global configuration: {language_backend.name}") + return language_backend diff --git a/src/serena/project.py b/src/serena/project.py index e40c2dab..87d0f969 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -9,6 +9,7 @@ from sensai.util.logging import LogTime from sensai.util.string import TextBuilder, ToStringMixin from serena.config.serena_config import ( + LanguageBackend, ProjectConfig, SerenaConfig, ) @@ -121,6 +122,16 @@ class Project(ToStringMixin): def project_name(self) -> str: return self.project_config.project_name + @property + def language_backend(self) -> LanguageBackend: + # The backend configuration is fundamentally owned by the agent, so it takes + # precedence. (Note: The agent does not necessary honour the project's choice, + # as it may be invalid.) + if self._agent is not None: + return self._agent.get_language_backend() + else: + return self.serena_config.determine_language_backend(self.project_config) + @classmethod def load( cls, From 3096ac4fe2567c11f3156bd65c51d4c435de3afd Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 1 Jul 2026 14:19:25 +0200 Subject: [PATCH 073/170] Fix in Project._is_ignored_relative_path: Filtering of files for source files ignored the language backend The check is really only possible for LSP, so the flag is now ignored for JetBrains (where there is no known list of languages). --- CHANGELOG.md | 2 ++ src/serena/project.py | 25 ++++++++++++++----------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c12bb3ec..8a26a59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Update prompts/instructions: Serena instructions manual, modes (editing, interactive) - Allow structured tool output to be configured on a per-context basis, disabling it for Claude Code (which does not correctly unpack structured output) #1042 + - Fix: Project-specific filtering of files for source files ignored the language backend. + The check is really only possible for LSP. * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/project.py b/src/serena/project.py index 87d0f969..8152a52a 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -231,17 +231,20 @@ class Project(ToStringMixin): 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) - if is_file and ignore_non_source_files: - is_file_in_supported_language = False - for language in self.project_config.languages: - fn_matcher = language.get_source_fn_matcher() - if fn_matcher.is_relevant_filename(abs_path): - is_file_in_supported_language = True - break - if not is_file_in_supported_language: - return True + # check code file restriction (depending on backend) + if ignore_non_source_files: + # apply restriction only for LSP backend, which enumerates known languages + # and therefore can determine whether a file is a source file or not + if self.language_backend.is_lsp(): + if os.path.isfile(abs_path): + is_file_in_supported_language = False + for language in self.project_config.languages: + fn_matcher = language.get_source_fn_matcher() + if fn_matcher.is_relevant_filename(abs_path): + is_file_in_supported_language = True + break + if not is_file_in_supported_language: + return True # Create normalized path for consistent handling rel_path = Path(relative_path) From d5d88d72eb3379115fa8f4e3895d5c4dd28d9d3a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 1 Jul 2026 16:55:36 +0200 Subject: [PATCH 074/170] Improve handling of glob support in FileProxy, fixing tests --- src/serena/util/file_proxy.py | 14 +++++++++++--- test/serena/test_text_utils.py | 3 +++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py index 13fc070a..53685dc8 100644 --- a/src/serena/util/file_proxy.py +++ b/src/serena/util/file_proxy.py @@ -19,6 +19,12 @@ class FileProxy(ABC): def get_relative_path(self) -> str: """:return: the relative path reported by Serena (actual relative path or encoded external path)""" + @abstractmethod + def is_glob_supported(self): + """ + :return: whether the proxy supports glob filtering based on its relative path + """ + class LocalProjectFileProxy(FileProxy): def __init__(self, relative_path: str, project: "Project"): @@ -33,6 +39,9 @@ class LocalProjectFileProxy(FileProxy): def get_relative_path(self) -> str: return self._relative_path + def is_glob_supported(self): + return True + class FileCollection: def __init__(self, file_proxies: list[FileProxy]): @@ -67,9 +76,8 @@ class FileCollection: filtered_files = [] for f in self._file_proxies: - if isinstance(f, LocalProjectFileProxy): - local_file: LocalProjectFileProxy = f - path = local_file.get_relative_path() + if f.is_glob_supported(): + path = f.get_relative_path() if include_glob_matcher: if not include_glob_matcher.matches(path): log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}") diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index bdfa6e76..71b0aad6 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -233,6 +233,9 @@ class MockFileProxy(FileProxy): def get_relative_path(self) -> str: return self.relative_path + def is_glob_supported(self): + return True + class MockFileCollection(FileCollection): def __init__(self, file_paths, mock_reader: Callable[[str], str] = mock_reader_always_match): From dcbd8ce064fd057858ba49c9f74b433b95d9f17a Mon Sep 17 00:00:00 2001 From: Jordan Carney <carney1@gmail.com> Date: Wed, 1 Jul 2026 12:12:05 -0400 Subject: [PATCH 075/170] Add activation_command to project config (#1623) Add activation_command to project config Adds two optional fields to ProjectConfig: - activation_command: shell command run in the project root before the language backend (LSP or JetBrains) initialises - activation_command_timeout: safety backstop (default 180s); on expiry the process tree is killed and activation continues The command is only executed for trusted projects (trusted_project_path_patterns). Exit code is the primary completion signal; failures and timeouts are logged but never abort activation (no return channel to the LLM at that point). Closes #1606 --------- Co-authored-by: Dominik Jain <dominik.jain@oraios-ai.de> --- CHANGELOG.md | 8 +- docs/02-usage/040_workflow.md | 1 + src/serena/agent.py | 44 ++++++++++ src/serena/config/serena_config.py | 13 +++ src/serena/resources/project.template.yml | 13 +++ test/serena/config/test_serena_config.py | 48 +++++++++++ test/serena/test_activation_command.py | 100 ++++++++++++++++++++++ 7 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 test/serena/test_activation_command.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a26a59a..3fcd8b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ Status of the `main` branch. Changes prior to the next official version change w * General: - Add notion of trusted projects via new global configuration setting `trusted_project_path_patterns`. Current effects: - - `ls_specific_settings` defined in project configurations will only be applied for trusted projects + - `ls_specific_settings` defined in project configurations will only be applied for trusted projects + - `activation_command` (and `activation_command_timeout`) defined in project configurations will only + be executed for trusted projects: an optional shell command run in the project root before the + language backend initialises (e.g. to generate source files a language server needs to index). + Exit code is the primary completion signal; `activation_command_timeout` (default 180s) is a safety + backstop — on expiry the process is killed and activation continues. Failures and timeouts are + logged but do not abort activation. - Fix: context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); file access is now guarded with path detection (file ending or path separator must be present) diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index 8fa10eca..fa710498 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -53,6 +53,7 @@ The file allows you to configure ... * write access * [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos * an initial prompt that shall be passed to the LLM whenever the project is activated + * a shell command to run upon project activation (prior to language backend initialisation) * the set of tools and modes to use for the project * and some other settings. diff --git a/src/serena/agent.py b/src/serena/agent.py index 953e4241..637e8ae1 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -63,6 +63,7 @@ from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language from solidlsp.util import subprocess_util +from solidlsp.util.subprocess_util import terminate_process_tree_with_kill_fallback if TYPE_CHECKING: from serena.gui_log_viewer import GuiLogViewer @@ -1200,6 +1201,7 @@ class SerenaAgent: self._update_active_tools() def init_project_services() -> None: + self._run_project_activation_command(project) self._init_active_project_language_backend() # initialise the project's language backend in the background @@ -1214,6 +1216,48 @@ class SerenaAgent: return True + @staticmethod + def _run_project_activation_command(project: Project) -> None: + """ + Runs the given project's activation_command (if set and the project is trusted). + Failures are logged. + """ + activation_command = project.project_config.activation_command + if not activation_command: + return + if not project.is_trusted(): + log.warning( + f"Project path {project.project_root} is not trusted, ignoring activation_command " + "from project configuration. To trust the project, modify the trusted path patterns " + "in the global configuration." + ) + return + timeout = project.project_config.activation_command_timeout + cmd = subprocess_util.convert_shell_cmd(activation_command) + log.info(f"Running activation_command for project '{project.project_name}': {cmd}") + try: + with LogTime("Project activation command", logger=log): + p = subprocess.Popen( + cmd, + shell=True, + cwd=project.project_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **subprocess_util.subprocess_kwargs(), + ) + try: + _, stderr = p.communicate(timeout=timeout) + if p.returncode != 0: + log.error(f"activation_command for project '{project.project_name}' failed (exit {p.returncode}): {stderr.strip()}") + except subprocess.TimeoutExpired: + log.error( + f"Activation_command for project '{project.project_name}' timed out after " + f"{timeout}s; terminating process and continuing with backend initialisation." + ) + terminate_process_tree_with_kill_fallback(p, terminate_timeout=5.0, process_name="activation_command") + except Exception: + log.exception(f"Unexpected error running activation_command for project '{project.project_name}'") + def _init_active_project_language_backend(self) -> None: """ Initialises the active project's language backend diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 2b580082..390eb2b2 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -308,6 +308,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING + activation_command: str | None = None + activation_command_timeout: float = 180.0 # internal fields which are not mapped to/from the configuration file (must start with "_") _local_override_keys: list[str] = field(default_factory=list) @@ -493,6 +495,15 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}" ) from e + # Validate activation_command_timeout + activation_command_timeout_raw = data.get("activation_command_timeout", 180.0) + try: + activation_command_timeout = float(activation_command_timeout_raw) + except (TypeError, ValueError) as e: + raise ValueError(f"activation_command_timeout must be a number, got: {activation_command_timeout_raw}") from e + if activation_command_timeout <= 0: + raise ValueError(f"activation_command_timeout must be positive, got: {activation_command_timeout}") + # Validate symbol_info_budget symbol_info_budget_raw = data["symbol_info_budget"] symbol_info_budget = symbol_info_budget_raw @@ -540,6 +551,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): default_modes=data["default_modes"], symbol_info_budget=symbol_info_budget, ls_specific_settings=data.get("ls_specific_settings", {}), + activation_command=data.get("activation_command"), + activation_command_timeout=activation_command_timeout, _local_override_keys=local_override_keys, ) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index a4dc26ee..5d0b7441 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -37,6 +37,19 @@ languages: ["python"] # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180 + # line ending convention to use when writing source files. # Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) # This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 0a3cb43c..e66ad32e 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -607,3 +607,51 @@ class TestMemoriesManagerCustomPath: manager.save_memory("topic_b", "content b", is_tool_context=False) memories = manager.list_project_memories() assert sorted(memories.get_full_list()) == ["topic_a", "topic_b"] + + +class TestProjectConfigActivationCommand: + """Tests for the activation_command and activation_command_timeout fields.""" + + def _base_data(self) -> dict: + data, _ = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) + data["project_name"] = "test" + data["languages"] = ["python"] + return data + + def test_activation_command_defaults_to_none(self): + config = ProjectConfig(project_name="test", languages=[Language.PYTHON]) + assert config.activation_command is None + + def test_activation_command_timeout_default(self): + config = ProjectConfig(project_name="test", languages=[Language.PYTHON]) + assert config.activation_command_timeout == 180.0 + + def test_activation_command_parsed_from_dict(self): + data = self._base_data() + data["activation_command"] = "npx nx run-many -t build" + data["activation_command_timeout"] = 300 + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.activation_command == "npx nx run-many -t build" + assert config.activation_command_timeout == 300.0 + + def test_activation_command_defaults_when_absent(self): + data = self._base_data() + data.pop("activation_command", None) + data.pop("activation_command_timeout", None) + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.activation_command is None + assert config.activation_command_timeout == 180.0 + + def test_activation_command_timeout_zero_raises(self): + data = self._base_data() + data["activation_command"] = "echo hi" + data["activation_command_timeout"] = 0 + with pytest.raises(ValueError, match="activation_command_timeout must be positive"): + ProjectConfig._from_dict(data, local_override_keys=[]) + + def test_activation_command_timeout_negative_raises(self): + data = self._base_data() + data["activation_command"] = "echo hi" + data["activation_command_timeout"] = -10 + with pytest.raises(ValueError, match="activation_command_timeout must be positive"): + ProjectConfig._from_dict(data, local_override_keys=[]) diff --git a/test/serena/test_activation_command.py b/test/serena/test_activation_command.py new file mode 100644 index 00000000..39bca274 --- /dev/null +++ b/test/serena/test_activation_command.py @@ -0,0 +1,100 @@ +"""Tests for SerenaAgent._run_activation_command.""" + +import logging +import sys +from pathlib import Path + +import pytest + +from serena.config.serena_config import ProjectConfig, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _make_project( + project_root: Path, + activation_command: str | None = None, + activation_command_timeout: float = 180.0, + trusted: bool = True, +) -> Project: + """Create a minimal Project pointing at project_root with the given activation settings.""" + serena_config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + trusted_project_path_patterns=["**"] if trusted else [], + ) + project_config = ProjectConfig( + project_name="test-activation", + languages=[Language.PYTHON], + activation_command=activation_command, + activation_command_timeout=activation_command_timeout, + ) + return Project( + project_root=str(project_root), + project_config=project_config, + serena_config=serena_config, + ) + + +class TestRunActivationCommand: + """Unit tests for SerenaAgent._run_activation_command.""" + + def _make_agent(self, project: Project): + from serena.agent import SerenaAgent + + agent = SerenaAgent.__new__(SerenaAgent) + # wire up only what _run_activation_command needs + agent.serena_config = project.serena_config + return agent + + def test_no_command_does_nothing(self, tmp_path: Path, caplog): + project = _make_project(tmp_path, activation_command=None) + agent = self._make_agent(project) + with caplog.at_level(logging.INFO): + agent._run_project_activation_command(project) + assert "activation_command" not in caplog.text + + def test_trusted_command_runs_in_project_root(self, tmp_path: Path): + sentinel = tmp_path / "sentinel.txt" + # write a sentinel file from within the command to confirm cwd is project_root + if sys.platform == "win32": + cmd = "type nul > sentinel.txt" + else: + cmd = "touch sentinel.txt" + project = _make_project(tmp_path, activation_command=cmd, trusted=True) + agent = self._make_agent(project) + agent._run_project_activation_command(project) + assert sentinel.exists(), "Command did not run in project root (sentinel file missing)" + + def test_untrusted_project_skips_command(self, tmp_path: Path, caplog): + sentinel = tmp_path / "sentinel.txt" + cmd = "touch sentinel.txt" if sys.platform != "win32" else "type nul > sentinel.txt" + project = _make_project(tmp_path, activation_command=cmd, trusted=False) + agent = self._make_agent(project) + with caplog.at_level(logging.WARNING): + agent._run_project_activation_command(project) + assert not sentinel.exists(), "Command must not run for untrusted project" + assert "not trusted" in caplog.text + + def test_failing_command_logs_error_and_continues(self, tmp_path: Path, caplog): + cmd = "exit 1" if sys.platform != "win32" else "cmd /c exit 1" + project = _make_project(tmp_path, activation_command=cmd, trusted=True) + agent = self._make_agent(project) + with caplog.at_level(logging.ERROR): + agent._run_project_activation_command(project) # must not raise + assert any("failed" in r.message.lower() for r in caplog.records if r.levelno == logging.ERROR) + + @pytest.mark.slow + def test_timeout_kills_process_and_continues(self, tmp_path: Path, caplog): + """A hanging command must be killed and activation must continue.""" + cmd = 'python3 -c "import time; time.sleep(60)"' + project = _make_project( + tmp_path, + activation_command=cmd, + activation_command_timeout=1.0, # 1s — fast for the test + trusted=True, + ) + agent = self._make_agent(project) + with caplog.at_level(logging.ERROR): + agent._run_project_activation_command(project) # must not hang or raise + assert any("timed out" in r.message.lower() for r in caplog.records if r.levelno == logging.ERROR) From b8a0882d299bcc56734ebc2df4f50d69174fc843 Mon Sep 17 00:00:00 2001 From: weiconghe <46336277+weiconghe@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:53:22 +0800 Subject: [PATCH 076/170] fix(file_system): handle PermissionError on scandir in gitignore walker (#1634) Wrap os.scandir() with PermissionError guard in _iter_gitignore_files' scan() function to prevent crash when a subdirectory is not readable. Previously, PermissionError on the scandir call itself crashed the entire gitignore walker, even when the path was listed in ignored_paths. Fixes oraios/serena#1624 Co-authored-by: Claude <noreply@anthropic.com> --- CHANGELOG.md | 1 + src/serena/util/file_system.py | 7 ++++- test/serena/util/test_file_system.py | 40 +++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fcd8b5f..387e9146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Status of the `main` branch. Changes prior to the next official version change w (which does not correctly unpack structured output) #1042 - Fix: Project-specific filtering of files for source files ignored the language backend. The check is really only possible for LSP. + - Fix: File system permission errors during gitignore scanning were not caught #1624 * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/file_system.py b/src/serena/util/file_system.py index 6ec1582e..06cedc8c 100644 --- a/src/serena/util/file_system.py +++ b/src/serena/util/file_system.py @@ -164,7 +164,12 @@ class GitignoreParser: queue: list[str] = [self.repo_root] def scan(abs_path: str | None) -> Iterator[str]: - for entry in os.scandir(abs_path): + try: + entries = os.scandir(abs_path) + except PermissionError as ex: + log.debug(f"Skipping unreadable directory {abs_path}: {ex}") + return + for entry in entries: try: if entry.is_dir(follow_symlinks=follow_symlinks): queue.append(entry.path) diff --git a/test/serena/util/test_file_system.py b/test/serena/util/test_file_system.py index 95ce9e61..ca8a0872 100644 --- a/test/serena/util/test_file_system.py +++ b/test/serena/util/test_file_system.py @@ -3,7 +3,6 @@ import shutil import tempfile from pathlib import Path -# Assuming the gitignore parser code is in a module named 'gitignore_parser' from pathspec import PathSpec from serena.util.file_system import GitignoreParser, GitignoreSpec, match_path @@ -715,3 +714,42 @@ src/*.o # foo.txt in other/ should NOT be ignored (outside foo/ subtree) assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" + + +class TestGitignoreParserPermissionError: + """Test PermissionError handling in GitignoreParser.""" + + def setup_method(self): + self.test_dir = tempfile.mkdtemp() + self.repo_path = Path(self.test_dir) + + def teardown_method(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_scandir_permission_error_on_subdirectory(self): + """ + Test that GitignoreParser does not crash when a subdirectory + is not readable (PermissionError on os.scandir). + + Regression test for https://github.com/oraios/serena/issues/1624 + """ + # Create a root .gitignore + gitignore = self.repo_path / ".gitignore" + gitignore.write_text("*.log\n") + + # Create an unreadable subdirectory + unreadable = self.repo_path / "unreadable_dir" + unreadable.mkdir() + + # Remove read permissions (Unix only; on Windows this is a no-op) + old_mode = os.stat(unreadable).st_mode + os.chmod(unreadable, 0o000) + + try: + # This should not raise PermissionError + parser = GitignoreParser(str(self.repo_path)) + # Parser should still function + assert parser.should_ignore("test.log") + finally: + # Restore permissions so teardown can clean up + os.chmod(unreadable, old_mode) From 7d0481b8a4a344f2205a586fcfc16954eb312af8 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 2 Jul 2026 10:54:53 +0200 Subject: [PATCH 077/170] Add poe ty as alias for type-check --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 232a4dea..bdd04a6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,7 @@ format = ["_ruff_fix", "_ruff_format"] _ty_core = "ty check src/serena src/solidlsp" _ty_test = "ty check test --exclude test/resources" type-check = ["_ty_core", "_ty_test"] +ty = ["type-check"] # docs _autogen_docs = "python docs/autogen_docs.py" _sphinx_build = "sphinx-build -b html docs docs/_build -W --keep-going" From 3c241bab0d07d8adba8ef4f6c49676c954559dee Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 2 Jul 2026 14:16:41 +0200 Subject: [PATCH 078/170] Update project file --- .serena/project.yml | 46 ++++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index e6db2f97..f738bebf 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,24 +3,27 @@ project_name: "serena" # list of languages for which language servers are started; choose from: -# al ansible bash clojure cpp -# cpp_ccls crystal csharp csharp_omnisharp dart -# elixir elm erlang fortran fsharp -# go groovy haskell haxe hlsl -# java json julia kotlin lean4 -# lua luau markdown matlab msl -# nix ocaml pascal perl php -# php_phpactor powershell python python_jedi python_ty -# r rego ruby ruby_solargraph rust -# scala solidity swift systemverilog terraform -# toml typescript typescript_vts vue yaml -# zig +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -73,7 +76,7 @@ initial_prompt: | Comments: When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase clearly defines *what* it is. Any details then follow in subsequent sentences. - + Pull requests: Read `mem:creating_pull_requests` when asked to participate in the creation of a pull request. @@ -136,8 +139,8 @@ ignored_memory_patterns: [] # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} # list of mode names to be activated additionally for this project, e.g. ["query-projects"] @@ -155,3 +158,16 @@ added_modes: # - ../sibling-package # - ../shared-lib additional_workspace_folders: [] + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 From e93bbeedad9d39a506c6934dc48c97a1999eeb7f Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 2 Jul 2026 14:27:00 +0200 Subject: [PATCH 079/170] solidlsp: Introduce LanguageServerDependencyProviderBaseCommand Generalises launch command creation from a base command which the user can override via custom settings. SinglePath and Uvx dependency providers are now specialisations of it; the uvx command construction was moved from common.py into the Uvx provider. The language support guide memory is updated accordingly. --- .../adding_new_language_support_guide.md | 26 +++- src/solidlsp/language_servers/common.py | 37 ------ src/solidlsp/ls.py | 123 +++++++++++++++--- 3 files changed, 127 insertions(+), 59 deletions(-) diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index bafa7526..2ac65b31 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -33,16 +33,30 @@ To implement a new language server using the DependencyProvider pattern: ``` The resource dir that is passed is the directory in which installed dependencies should be stored! -**Base Classes:** +**Base Classes** (choose the most specific one that fits): -- **`LanguageServerDependencyProviderSinglePath`** - For language servers with a single core dependency (e.g., an executable or JAR file) - - Provides automatic support for the `ls_path` custom setting, allowing users to override the core dependency path (if they have it installed it themselves) +- **`LanguageServerDependencyProviderUvx`** - For language servers distributed as a PyPI package, run on demand + via `uvx` / `uv x` (no installation step to implement) + - Simply instantiate it with the package name, pinned default version, entrypoint (console script) and optional `extra_args`; + the version can be overridden by the user via the configured `version_setting_key` custom setting + - Reference implementation: `PyrightServer` + +- **`LanguageServerDependencyProviderBaseCommand`** - For the common case where the + launch command is constructed from a *base command* (which the user can override via custom settings; handled generically) + - Implement `_create_default_base_command()` to return the default base command (executable + args), downloading/installing + dependencies beforehand if necessary + - Implement `_create_launch_command_from_base_command(base_command)` to add any further arguments, producing the + final launch command + +- **`LanguageServerDependencyProviderSinglePath`** - Alternative to inheriting from `...BaseCommand` directly for the case + of a single core dependency (e.g., an executable or JAR file); mostly present in existing implementations - for new + implementations, prefer `...BaseCommand` - Implement `_get_or_install_core_dependency()` to return the path to the core dependency, downloading/installing it automatically if necessary - Implement `_create_launch_command(core_path)` to build the full command from the core path - - Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer`, `PyrightServer` + - Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer` -- **`LanguageServerDependencyProvider`** - The base class, which can be directly inherited from for complex cases with multiple dependencies or custom setup - - Implement `create_launch_command()` directly +- **`LanguageServerDependencyProvider`** - The root base class, for complex cases with multiple dependencies or custom setup + - Implement `create_launch_command()` directly (note: no automatic support for user-level launch command overrides in this case) - Reference implementations: `EclipseJDTLS`, `CSharpLanguageServer`, `MatlabLanguageServer` **Implementation Pointers::** diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index ddc3fddd..4a28cca3 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -4,7 +4,6 @@ import logging import os import pathlib import platform -import shutil import subprocess from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace @@ -151,42 +150,6 @@ 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/ls.py b/src/solidlsp/ls.py index 558a9148..1147e1a7 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -22,7 +22,6 @@ 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 @@ -300,7 +299,42 @@ class LanguageServerDependencyProvider(ABC): return {} -class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvider, ABC): +class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvider, ABC): + """ + Special case of a dependency provider, where the launch command is constructed from a base command + (which can potentially be overridden by the user in LS-specific settings). + """ + + @abstractmethod + def _create_default_base_command(self) -> list[str]: + """ + Obtains the default base command for this language server, potentially downloading and installing dependencies + beforehand. + + Note: The user can override the base command, so this will only be run if no custom base command is provided. + + :return: the base command as a list containing the executable and its arguments + """ + + @abstractmethod + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + """ + Adds any additional arguments to the base command to create the final launch command. + + :param base_command: the base command + :return: the extended command + """ + + def create_launch_command(self) -> list[str]: + ls_path = self._custom_settings.get("ls_path", None) + if ls_path is not None: + base_command = [ls_path] + else: + base_command = self._create_default_base_command() + return self._create_launch_command_from_base_command(list(base_command)) + + +class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC): """ Special case of a dependency provider, where there is a single core dependency which provides the basis for the launch command. @@ -308,6 +342,13 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide The core dependency's path can be overridden by the user in LS-specific settings (SerenaConfig) via the key "ls_path". If the user provides the key, the specified path is used directly. Otherwise, the provider implementation is called to get or install the core dependency. + + Note: The inheritance from the BaseCommand class serves to allow user overrides to be handled + centrally, but implementations of this class do not necessarily follow the principle that + the base command is strictly extended. + Yet incompatibility arises only if (a) the user overrides the base command with a command that + has arguments, and (b) the implementation of this class does not construct the launch command + by appending arguments to the core dependency's path. """ @abstractmethod @@ -318,14 +359,6 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide :return: the core dependency's path (e.g. executable, jar, etc.) """ - def create_launch_command(self) -> list[str]: - path = self._custom_settings.get("ls_path", None) - if path is not None: - core_path = path - else: - core_path = self._get_or_install_core_dependency() - return self._create_launch_command(core_path) - @abstractmethod def _create_launch_command(self, core_path: str) -> list[str]: """ @@ -333,8 +366,33 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide :return: the launch command as a list containing the executable and its arguments """ + def _create_default_base_command(self) -> list[str]: + # We treat the core path as the only element of the base command, + # noting that the construction of the launch command from the base command + # is not necessarily to append arguments only. + core_path = self._get_or_install_core_dependency() + return [core_path] -class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider): + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + core_path = base_command[0] + cmd = self._create_launch_command(core_path) + if len(base_command) == 1: + # This is the regular case, where the base command consists only of the core + # dependency's path, and the launch command is constructed from it. + return cmd + else: + # In this case, the user has overridden the base command via LS-specific settings + # with a command that has arguments and therefore does not map cleanly to + # the single path assumption. However, in special cases where the full command + # was constructed by appending arguments to the core dependency's path, + # we simply assume that the provided base command can be substituted. + if cmd[0] == core_path: + return base_command + cmd[1:] + else: + raise ValueError("Language server base launch command with arguments unsupported") + + +class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCommand): """ Dependency provider for language servers distributed as a PyPI package, run on demand via ``uvx`` / ``uv x``. @@ -343,6 +401,8 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider): already-installed language server executable, in which case it is launched directly, bypassing uv entirely. """ + DEFAULT_UVX_PYTHON_VERSION = "3.13" + def __init__( self, custom_settings: "SolidLSPSettings.CustomLSSettings", @@ -368,12 +428,43 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider): 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] + @staticmethod + def _build_uvx_base_command( + package: str, + version: str, + entrypoint: 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 python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing). + """ + base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint] + + 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 _create_default_base_command(self): 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) + return self._build_uvx_base_command(self._package, version, self._entrypoint) + + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + return base_command + list(self._extra_args) class SolidLanguageServer(ABC): From 60abdd5297be9d1b499a80bd0ebf5a3b2d8e1374 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 2 Jul 2026 15:29:00 +0200 Subject: [PATCH 080/170] Support ls_args and ls_extra_args centrally in LanguageServerDependencyProviderBaseCommand * ls_args overrides the internal command builder, fully defining the arguments * ls_extra_args appends additional arguments to the launch command --- CHANGELOG.md | 1 + docs/02-usage/050_configuration.md | 36 ++++++++++++++++++------------ src/solidlsp/ls.py | 33 ++++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 387e9146..c616bbea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. + - Add new launch command customization options: `ls_args`, `ls_extra_args` * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 8aa12542..ee00ea57 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -216,31 +216,39 @@ ls_specific_settings: ``` (override-ls-path)= -#### Overriding the Language Server Path +#### Customizing the Language Server Launch Command -Most of Serena's language servers, particularly those that use a single core path for the language server (e.g. the main executable), -support overriding that path via the `ls_path` setting. -Therefore, if you have installed the language server yourself and want to use your installation -instead of Serena's managed installation, you can set the `ls_path` setting as follows: +Most of Serena's language servers construct the command that launches the language server process from +a *base command* or a *core dependency*. +For these language servers, the following settings can be used to customize the launch command: + +| Setting | Description | +|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ls_path` (string) | overrides the path of the language server's core dependency (e.g. its executable or a JAR file). Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | +| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | +| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | + +* If you set `ls_args`, the internal command construction (which may do more than to append arguments to a base command) is bypassed. + You can define the full launch command by providing both `ls_path` and `ls_args`. +* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` to override the path of the core dependency. +* `ls_extra_args` is always appended to the end of the launch command. + +Example: ```yaml ls_specific_settings: <language>: ls_path: "/path/to/language-server" + ls_extra_args: ["--log-level=debug"] ``` -This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`, -and by some additional wrappers that explicitly expose `ls_path`. +These settings are supported by all language servers whose dependency provider derives from +`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some wrappers explicitly. Common examples include: `ansible`, `bash`, `bsl`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`, `php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`. -Note: `angular` does **not** support `ls_path` — the Angular language server is part of a multi-process orchestration -(`ngserver` plus a companion TypeScript language server with the `@angular/language-service` plugin and an HTML -companion) where the dependency layout matters; use the version overrides documented in the Angular section below -to pin specific releases of the bundled stack. - -If a language server supports `ls_path`, setting it bypasses Serena's managed download or install for that server. -In that case, any server-specific version or registry settings only apply when `ls_path` is not set. +If `ls_path` is set, Serena's managed download or install is bypassed for that language server. +In that case, any server-specific version or registry settings do not apply. #### AL diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 1147e1a7..7b116419 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -301,8 +301,14 @@ class LanguageServerDependencyProvider(ABC): class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvider, ABC): """ - Special case of a dependency provider, where the launch command is constructed from a base command - (which can potentially be overridden by the user in LS-specific settings). + Special case of a dependency provider, where the launch command is constructed from a base command. + + The user can configure aspects of the launch command in LS-specific settings: + + * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), + from which the base command is formed, bypassing Serena's managed installation + * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command + * ``ls_extra_args``: additional arguments to append to the launch command """ @abstractmethod @@ -326,12 +332,33 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid """ def create_launch_command(self) -> list[str]: + # obtain base command ls_path = self._custom_settings.get("ls_path", None) if ls_path is not None: base_command = [ls_path] else: base_command = self._create_default_base_command() - return self._create_launch_command_from_base_command(list(base_command)) + + # create launch command from base command + ls_args = self._custom_settings.get("ls_args") + if ls_args is not None: + if not isinstance(ls_args, list): + log.warning("The 'ls_args' setting should be a list of strings. Ignoring the provided value: %s", ls_args) + ls_args = None + if ls_args is not None: + cmd = list(base_command) + ls_args + else: + cmd = self._create_launch_command_from_base_command(list(base_command)) + + # add user-provided extra arguments (if any) + ls_extra_args = self._custom_settings.get("ls_extra_args", []) + if ls_extra_args: + if not isinstance(ls_extra_args, list): + log.warning("The 'ls_extra_args' setting should be a list of strings. Ignoring the provided value: %s", ls_extra_args) + else: + cmd = cmd + ls_extra_args + + return cmd class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC): From 8c418dc7992c470bb4e181d10255f060b8543223 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 2 Jul 2026 16:11:59 +0200 Subject: [PATCH 081/170] Support ls_base_cmd in LanguageServerDependencyProviderBaseCommand ls_base_cmd allows the user to override the base command with a multi-element command (e.g. ["npx", "-y", "/my/local/package"]), generalising ls_path, which it takes precedence over. --- CHANGELOG.md | 2 +- docs/02-usage/050_configuration.md | 16 ++++++++-------- src/solidlsp/ls.py | 17 ++++++++++++----- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c616bbea..02c78676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. - - Add new launch command customization options: `ls_args`, `ls_extra_args` + - Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd` * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index ee00ea57..c69faaaf 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -222,15 +222,15 @@ Most of Serena's language servers construct the command that launches the langua a *base command* or a *core dependency*. For these language servers, the following settings can be used to customize the launch command: -| Setting | Description | -|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ls_path` (string) | overrides the path of the language server's core dependency (e.g. its executable or a JAR file). Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | -| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | -| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | +| Setting | Description | +|--------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ls_path` (string) or `ls_base_cmd` (list) | overrides the path of the language server's core dependency (`ls_path`), e.g. its executable or a JAR file, or a base command for its execution (`ls_base_cmd`), e.g. `["npx", "-y", "/my/local/package"]`. Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | +| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | +| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | * If you set `ls_args`, the internal command construction (which may do more than to append arguments to a base command) is bypassed. - You can define the full launch command by providing both `ls_path` and `ls_args`. -* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` to override the path of the core dependency. + You can define the full launch command by providing both `ls_path`/`ls_base_cmd` and `ls_args`. +* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` or `ls_base_cmd` to override the path of the core dependency/the base command. * `ls_extra_args` is always appended to the end of the launch command. Example: @@ -243,7 +243,7 @@ ls_specific_settings: ``` These settings are supported by all language servers whose dependency provider derives from -`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some wrappers explicitly. +`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some implementations explicitly. Common examples include: `ansible`, `bash`, `bsl`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`, `php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`. diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 7b116419..a769a3b3 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -305,6 +305,7 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid The user can configure aspects of the launch command in LS-specific settings: + * ``ls_base_cmd`: overrides the base command * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), from which the base command is formed, bypassing Serena's managed installation * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command @@ -333,11 +334,17 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid def create_launch_command(self) -> list[str]: # obtain base command - ls_path = self._custom_settings.get("ls_path", None) - if ls_path is not None: - base_command = [ls_path] - else: - base_command = self._create_default_base_command() + base_command = self._custom_settings.get("ls_base_cmd", None) + if base_command is not None and not isinstance(base_command, list): + log.warning("The 'ls_base_cmd' setting should be a list of strings. Ignoring the provided value: %s", base_command) + base_command = None + if base_command is None: + ls_path = self._custom_settings.get("ls_path", None) + if ls_path is not None: + base_command = [ls_path] + else: + # default case: base command is constructed by the provider implementation + base_command = self._create_default_base_command() # create launch command from base command ls_args = self._custom_settings.get("ls_args") From a7501b1c69574558790b7b9f43690b77d719b1cb Mon Sep 17 00:00:00 2001 From: bymebyu <bymebyu@naver.com> Date: Fri, 3 Jul 2026 02:39:28 +0900 Subject: [PATCH 082/170] Fix: language composition percentages diluted by unrecognised files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit determine_programming_language_composition computed percentages over the total file count, so files belonging to no supported language (images, plain text, licenses, lock files) diluted the numbers — a repository with 2 Python files and 23 assets reported Python at 8%. Percentages are now relative to files matching at least one supported language, and the counting runs in a single pass over the file list instead of one pass per language. The only consumer (ProjectConfig.autogenerate) sorts languages by percentage, which is unaffected by the uniform rescaling. --- CHANGELOG.md | 5 ++- src/serena/util/inspection.py | 48 ++++++++++++++++------------- test/serena/util/test_inspection.py | 45 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 23 deletions(-) create mode 100644 test/serena/util/test_inspection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 387e9146..d6e516d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,10 @@ Status of the `main` branch. Changes prior to the next official version change w (which does not correctly unpack structured output) #1042 - Fix: Project-specific filtering of files for source files ignored the language backend. The check is really only possible for LSP. - - Fix: File system permission errors during gitignore scanning were not caught #1624 + - Fix: File system permission errors during gitignore scanning were not caught #1624 + - During project creation, language composition percentages are now computed relative to the total number + of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage + computation. * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py index a0e3e96a..440d7c5b 100644 --- a/src/serena/util/inspection.py +++ b/src/serena/util/inspection.py @@ -31,36 +31,40 @@ def determine_programming_language_composition(repo_path: str) -> dict[Language, """ Determine the programming language composition of a repository. - :param repo_path: Path to the repository to analyze + Percentages are computed relative to the number of files that match at least + one supported language, not the total file count. This prevents files that + belong to no supported language (images, plain text, licenses, lock files, etc.) + from diluting language percentages in repositories where such files dominate. - :return: Dictionary mapping languages to percentages of files matching each language + :param repo_path: Path to the repository to analyze + :return: Dictionary mapping languages to percentages of recognised source files + matching each language (denominator = files matched by at least one language) """ all_files = find_all_non_ignored_files(repo_path) if not all_files: return {} - # Count files for each language + # collect all language matchers once + all_languages = list(Language.iter_all(include_experimental=False)) + matchers = {lang: lang.get_source_fn_matcher() for lang in all_languages} + + # count files per language in a single pass over the files language_counts: dict[Language, int] = {} - total_files = len(all_files) - - for language in Language.iter_all(include_experimental=False): - matcher = language.get_source_fn_matcher() - count = 0 - - for file_path in all_files: - # Use just the filename for matching, not the full path - filename = os.path.basename(file_path) + recognised_files = 0 + for file_path in all_files: + # Use just the filename for matching, not the full path + filename = os.path.basename(file_path) + matched_any = False + for lang, matcher in matchers.items(): if matcher.is_relevant_filename(filename): - count += 1 + language_counts[lang] = language_counts.get(lang, 0) + 1 + matched_any = True + if matched_any: + recognised_files += 1 - if count > 0: - language_counts[language] = count + if recognised_files == 0: + return {} - # Convert counts to percentages - language_percentages: dict[Language, float] = {} - for language, count in language_counts.items(): - percentage = (count / total_files) * 100 - language_percentages[language] = round(percentage, 2) - - return language_percentages + # convert to percentages relative to recognised source files only + return {lang: round(count / recognised_files * 100, 2) for lang, count in language_counts.items()} diff --git a/test/serena/util/test_inspection.py b/test/serena/util/test_inspection.py new file mode 100644 index 00000000..90378aac --- /dev/null +++ b/test/serena/util/test_inspection.py @@ -0,0 +1,45 @@ +"""Unit tests for :func:`serena.util.inspection.determine_programming_language_composition`.""" + +from pathlib import Path + +from serena.util.inspection import determine_programming_language_composition +from solidlsp.ls_config import Language + + +def _touch(directory: Path, *names: str) -> None: + for name in names: + (directory / name).write_text("content", encoding="utf-8") + + +class TestDetermineProgrammingLanguageComposition: + def test_single_language_repo(self, tmp_path: Path) -> None: + _touch(tmp_path, "a.py", "b.py", "c.py") + composition = determine_programming_language_composition(str(tmp_path)) + assert composition[Language.PYTHON] == 100.0 + + def test_unrecognised_files_do_not_dilute_percentages(self, tmp_path: Path) -> None: + # 2 source files vs many files that belong to no supported language + _touch(tmp_path, "main.py", "util.py") + _touch(tmp_path, *[f"note_{i}.txt" for i in range(20)]) + _touch(tmp_path, "logo.png", "LICENSE", "data.csv") + + composition = determine_programming_language_composition(str(tmp_path)) + + # previously: 2 / 25 files = 8% — now the denominator is recognised source files only + assert composition[Language.PYTHON] == 100.0 + + def test_mixed_language_percentages_relative_to_recognised_files(self, tmp_path: Path) -> None: + _touch(tmp_path, "a.py", "b.py", "c.py", "d.go") + _touch(tmp_path, *[f"asset_{i}.dat" for i in range(50)]) + + composition = determine_programming_language_composition(str(tmp_path)) + + assert composition[Language.PYTHON] == 75.0 + assert composition[Language.GO] == 25.0 + + def test_repo_without_recognised_files(self, tmp_path: Path) -> None: + _touch(tmp_path, "readme.txt", "logo.png") + assert determine_programming_language_composition(str(tmp_path)) == {} + + def test_empty_repo(self, tmp_path: Path) -> None: + assert determine_programming_language_composition(str(tmp_path)) == {} From 6f1f05407d727a856f435ab1b22d84ffb3316f83 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 1 Jul 2026 16:43:29 +0200 Subject: [PATCH 083/170] Introduce InitializeParamsBuilder base class and default implementation which sets common settings centrally: - processId - rootPath - rootUri - clientInfo - workspaceFolders Apply builder in SolidLanguageServer, adding new abstract method _create_base_initialize_params to be implemented by subclasses. Applied in PyrightServer. --- src/solidlsp/initialize_params.py | 58 +++++++++++++++++++ .../language_servers/pyright_server.py | 21 +------ src/solidlsp/ls.py | 32 ++++++++++ 3 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 src/solidlsp/initialize_params.py diff --git a/src/solidlsp/initialize_params.py b/src/solidlsp/initialize_params.py new file mode 100644 index 00000000..b8d81c0e --- /dev/null +++ b/src/solidlsp/initialize_params.py @@ -0,0 +1,58 @@ +import logging +import os +import pathlib +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Self, cast + +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, WorkspaceFolder + +if TYPE_CHECKING: + from solidlsp import SolidLanguageServer + + +log = logging.getLogger(__name__) + + +class InitializeParamsBuilder(ABC): + def __init__(self): + self._params = {} + + def with_base_options(self, options: dict | InitializeParams) -> Self: + self._params.update(options) + return self + + def _set(self, key: str, value: Any) -> None: + if key in self._params: + log.debug("Overriding existing option '%s' with new value: %s (old value: %s)", key, value, self._params[key]) + self._params[key] = value + + @abstractmethod + def _apply_updates(self) -> None: + """ + Applies implementation-specific updates to the options. + """ + + def build(self) -> InitializeParams: + self._apply_updates() + return cast(InitializeParams, self._params) + + +class DefaultInitializeParamsBuilder(InitializeParamsBuilder): + def __init__(self, ls: "SolidLanguageServer", set_workspace_folders: bool = True): + super().__init__() + self._ls = ls + self._set_workspace_folders = set_workspace_folders + + @staticmethod + def _create_workspace_folder_entry(path: str) -> WorkspaceFolder: + abs_path = os.path.abspath(path) + return {"uri": pathlib.Path(abs_path).as_uri(), "name": os.path.basename(abs_path)} + + def _apply_updates(self): + root_abs_path = self._ls.repository_root_path + self._set("processId", os.getpid()) + self._set("rootPath", root_abs_path) + self._set("rootUri", pathlib.Path(root_abs_path).as_uri()) + self._set("clientInfo", {"name": "Serena"}) + if self._set_workspace_folders: + self._set("workspaceFolders", [self._create_workspace_folder_entry(root_abs_path)]) diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index f369706c..b808d8f7 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -3,8 +3,6 @@ Provides Python specific instantiation of the LanguageServer class. Contains var """ import logging -import os -import pathlib import re import threading from typing import cast @@ -13,7 +11,6 @@ from overrides import override 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__) @@ -59,16 +56,8 @@ class PyrightServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize params for the Pyright Language Server. - """ - # Create basic initialization parameters + def _create_base_initialize_params(self) -> dict: initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": pathlib.Path(repository_absolute_path).as_uri(), "initializationOptions": { "exclude": [ "**/__pycache__", @@ -111,12 +100,8 @@ class PyrightServer(SolidLanguageServer): "publishDiagnostics": {"relatedInformation": True}, }, }, - "workspaceFolders": [ - {"uri": pathlib.Path(repository_absolute_path).as_uri(), "name": os.path.basename(repository_absolute_path)} - ], } - - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -230,7 +215,7 @@ class PyrightServer(SolidLanguageServer): self.server.start() # Send proper initialization parameters - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to pyright server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index a769a3b3..ab130be5 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -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.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer @@ -35,6 +36,7 @@ from solidlsp.lsp_protocol_handler.lsp_types import ( DefinitionParams, DocumentSymbol, ImplementationParams, + InitializeParams, LocationLink, RenameParams, SymbolInformation, @@ -3281,3 +3283,33 @@ class SolidLanguageServer(ABC): def is_running(self) -> bool: return self.server.is_running() + + def _create_initialize_params_builder(self) -> InitializeParamsBuilder: + return DefaultInitializeParamsBuilder(self) + + def _create_base_initialize_params(self) -> dict | InitializeParams: + """ + Subclasses should override this method to provide server-specific InitializeParams settings, + which provide the basis for the InitializeParams object sent to the language server during initialization. + + The returned dictionary is passed to the builder constructed by _create_initialize_params_builder() + in order to create the final InitializeParams object. + + The default builder implementation already sets the following keys, so implementations of this method + should not set them: + + - processId + - rootPath + - rootUri + - clientInfo + - workspaceFolders + + :return: the base InitializeParams settings + """ + raise NotImplementedError + + def _create_initialize_params(self) -> InitializeParams: + """ + Create the InitializeParams object to send to the language server during initialization. + """ + return self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build() From c904efed5e6369f2c7f71c989cd436400d419d44 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 2 Jul 2026 20:56:19 +0200 Subject: [PATCH 084/170] SolidLSP: Allow workspace folders to be flexibly configured across all language servers that use the newly introduced InitializeParamsBuilder - Add new configuration option `ls_workspace_folders` to allow indexed source folders to be specified explicitly. In monorepos, this allows the set of indexed folders to be restricted to a subset of the repository. #1627 - Rename configuration option `additional_workspace_folders` to `ls_additional_workspace_folders` and support the option across all language servers that use an InitializeParamsBuilder (previously limited strictly to the TypeScript server). Moved the option from SolidLSPSettings to LanguageServerConfig, where it belongs. Apply InitializeParamsBuilder in TypeScriptLanguageServer and its subclasses. --- .serena/project.yml | 70 ++++---- CHANGELOG.md | 5 + docs/02-usage/040_workflow.md | 29 +--- docs/02-usage/050_configuration.md | 3 - src/serena/config/serena_config.py | 32 ++-- src/serena/ls_manager.py | 9 +- src/serena/project.py | 2 +- src/serena/resources/project.template.yml | 55 ++++--- src/solidlsp/initialize_params.py | 13 +- .../angular_language_server.py | 4 +- .../svelte_language_server.py | 4 +- .../typescript_language_server.py | 18 +-- .../language_servers/vue_language_server.py | 4 +- src/solidlsp/ls.py | 153 +++++++++--------- src/solidlsp/ls_config.py | 54 ++++++- test/conftest.py | 14 +- test/solidlsp/test_ls_common.py | 37 ++++- 17 files changed, 313 insertions(+), 193 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index f738bebf..e2cafdba 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,23 +1,25 @@ -# the name by which the project can be referenced within Serena +# the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "serena" - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown +# list of languages for which language servers are started (LSP backend only); 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 powershell python -# python_jedi python_ty 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. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty 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 Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript @@ -148,17 +150,6 @@ ls_specific_settings: {} # See https://oraios.github.io/serena/02-usage/050_configuration.html#modes added_modes: -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). -# Paths can be absolute or relative to the project root. -# Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. -# Example: -# additional_workspace_folders: -# - ../sibling-package -# - ../shared-lib -additional_workspace_folders: [] - # optional shell command to run before the language backend (LSP or JetBrains) is initialised. # the command runs in the project root directory and is only executed if the project is trusted # (see trusted_project_path_patterns in the global configuration). @@ -171,3 +162,26 @@ activation_command: # maximum time in seconds to wait for activation_command to complete before killing it (default 180s). # must be a positive number. activation_command_timeout: 180.0 + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tool. +# Example: +# additional_workspace_folders: +# - "../sibling-package" +# - "../shared-lib" +ls_additional_workspace_folders: [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 831b4824..3b1a34c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,11 @@ Status of the `main` branch. Changes prior to the next official version change w - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. - Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd` + - Add new configuration option `ls_workspace_folders` to allow indexed source folders to be specified + explicitly. In monorepos, this allows the set of indexed folders to be restricted to a subset of + the repository. #1627 + - Rename configuration option `additional_workspace_folders` to `ls_additional_workspace_folders` + and support the option across all language servers (previously limited to TypeScript). * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index fa710498..b95c6f90 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -51,7 +51,7 @@ The file allows you to configure ... * the encoding used in source files * ignore rules * write access - * [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos + * the list of workspace folders to be processed by language servers (when using the LSP backend) * an initial prompt that shall be passed to the LLM whenever the project is activated * a shell command to run upon project activation (prior to language backend initialisation) * the set of tools and modes to use for the project @@ -70,33 +70,6 @@ You can specify local overrides for the settings in a `project.local.yml` file i (which, by default, is ignored by git). Any keys defined therein will override the respective key in `project.yml`. -(additional-workspace-folders)= -#### Additional Workspace Folders (Cross-Package References) - -In monorepos or multi-package setups, Serena's language server normally only sees symbols within the -project root. To enable cross-package references (e.g. `find_referencing_symbols` discovering usages -in sibling packages), configure `additional_workspace_folders` in your `project.yml`: - -```yaml -additional_workspace_folders: - - ../shared-lib - - ../api-client - - /absolute/path/to/another-package -``` - -Paths can be absolute or relative to the project root. Each folder is registered as an LSP workspace -folder, and the language server will discover symbols and references across all listed packages. - -**Currently supported for:** TypeScript. Other language servers will raise an error if this setting -is used with them. Support for additional languages can be added by implementing the -`_find_representative_source_file()` method in the respective language server class. - -:::{note} -Each additional workspace folder adds startup time, as the language server needs to index the -additional projects. For large monorepos, consider listing only the packages you actively need -cross-references for. -::: - (indexing)= ### Indexing diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index c69faaaf..22a8872d 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1001,9 +1001,6 @@ Supported settings: | `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. | | `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | -TypeScript supports [additional workspace folders](additional-workspace-folders) for cross-package -reference discovery. Configure `additional_workspace_folders` in `project.yml` to enable this feature. - #### Svelte Serena uses `svelte-language-server` for the `svelte` language key. Use `svelte` for Svelte projects instead of also listing `typescript`, unless you intentionally want multiple language servers active for the same files. diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 390eb2b2..6cb2a721 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -303,7 +303,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name: str languages: list[Language] ignored_paths: list[str] = field(default_factory=list) - additional_workspace_folders: list[str] = field(default_factory=list) + ls_workspace_folders: list[str] = field(default_factory=lambda: ["."]) + ls_additional_workspace_folders: list[str] = field(default_factory=list) read_only: bool = False ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" @@ -318,6 +319,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): SERENA_PROJECT_FILE = "project.yml" SERENA_LOCAL_PROJECT_FILE = "project.local.yml" FIELDS_WITHOUT_DEFAULTS = {"project_name", "languages"} + RENAMED_FIELDS = {"additional_workspace_folders": "ls_additional_workspace_folders"} YAML_COMMENT_NORMALISATION = YamlCommentNormalisation.LEADING """ the comment normalisation strategy to use when loading/saving project configuration files. @@ -443,9 +445,22 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): and `was_complete` will always be True. """ data = load_yaml(yml_path, comment_normalisation=comment_normalisation) + was_complete = True + + # backward compatibility + # NOTE: This must also work for project.local.yml files, which may be highly incomplete + # * handle single "language" field + if "languages" not in data and "language" in data: + data["languages"] = [data["language"]] + del data["language"] + # * handle renamed fields + for old_key, new_key in cls.RENAMED_FIELDS.items(): + if old_key in data and new_key not in data: + data[new_key] = data[old_key] + del data[old_key] + was_complete = False # apply defaults - was_complete = True if apply_defaults: for field_info in dataclasses.fields(cls): key = field_info.name @@ -458,13 +473,6 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): default_value = get_dataclass_default(cls, key) data.setdefault(key, default_value) - # backward compatibility - # NOTE: This must also work for project.local.yml files, which may be highly incomplete - # * handle single "language" field - if "languages" not in data and "language" in data: - data["languages"] = [data["language"]] - del data["language"] - # Note: Checks for validity of fields must not happen here but in _from_dict. # Here, the data may be incomplete, because this function is also used for # loading project.local.yml files. @@ -480,6 +488,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): the ProjectConfig dataclass :param local_override_keys: the list of keys that have been overridden from project.local.yml """ + # map languages to list of enum items, checking for errors lang_name_mapping = {"javascript": "typescript"} languages: list[Language] = [] for language_str in data["languages"]: @@ -526,7 +535,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): fixed_tools = data["fixed_tools"] or [] excluded_tools = data["excluded_tools"] or [] included_optional_tools = data["included_optional_tools"] or [] - additional_workspace_folders = data.get("additional_workspace_folders") or [] + additional_workspace_folders = data.get("ls_additional_workspace_folders") or [] if "base_modes" in data and data["base_modes"] is not None: log.warning("The base_modes setting in project.yml is deprecated and will be ignored.") @@ -535,7 +544,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name=data["project_name"], languages=languages, ignored_paths=ignored_paths, - additional_workspace_folders=additional_workspace_folders, + ls_workspace_folders=data["ls_workspace_folders"], + ls_additional_workspace_folders=additional_workspace_folders, excluded_tools=excluded_tools, fixed_tools=fixed_tools, included_optional_tools=included_optional_tools, diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 11d6feab..3b44a9af 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -5,7 +5,7 @@ from collections.abc import Iterator from sensai.util.logging import LogTime -from serena.config.serena_config import SerenaPaths +from serena.config.serena_config import ProjectConfig, SerenaPaths from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings @@ -22,25 +22,27 @@ class LanguageServerFactory: def __init__( self, project_root: str, + project_config: ProjectConfig, project_data_path: str, encoding: str, ignored_patterns: list[str], ls_timeout: float | None = None, ls_specific_settings: dict | None = None, - additional_workspace_folders: list[str] | None = None, trace_lsp_communication: bool = False, ): self.project_root = project_root + self.project_config = project_config self.project_data_path = project_data_path self.encoding = encoding self.ignored_patterns = ignored_patterns self.ls_timeout = ls_timeout self.ls_specific_settings = ls_specific_settings - self.additional_workspace_folders = additional_workspace_folders or [] self.trace_lsp_communication = trace_lsp_communication def create_language_server(self, language: Language) -> SolidLanguageServer: ls_config = LanguageServerConfig( + workspace_folders=self.project_config.ls_workspace_folders, + additional_workspace_folders=self.project_config.ls_additional_workspace_folders, code_language=language, ignored_paths=self.ignored_patterns, trace_lsp_communication=self.trace_lsp_communication, @@ -56,7 +58,6 @@ class LanguageServerFactory: solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_path=self.project_data_path, ls_specific_settings=self.ls_specific_settings or {}, - additional_workspace_folders=self.additional_workspace_folders, ), ) diff --git a/src/serena/project.py b/src/serena/project.py index 8152a52a..87a54adb 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -466,12 +466,12 @@ class Project(ToStringMixin): ) factory = LanguageServerFactory( project_root=self.project_root, + project_config=self.project_config, project_data_path=self._serena_data_folder, encoding=self.project_config.encoding, ignored_patterns=self._ignored_patterns, ls_timeout=ls_timeout, ls_specific_settings=ls_specific_settings, - additional_workspace_folders=self.project_config.additional_workspace_folders, trace_lsp_communication=self.serena_config.trace_lsp_communication, ) self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 5d0b7441..aa4247d7 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -1,23 +1,25 @@ -# the name by which the project can be referenced within Serena +# the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "project_name" - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown +# list of languages for which language servers are started (LSP backend only); 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 powershell python -# python_jedi python_ty 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. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty 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 Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript @@ -71,16 +73,27 @@ ignore_all_files_in_gitignore: true # See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: ["."] + +# list of additional workspace folder paths for cross-package reference support. # Paths can be absolute or relative to the project root. # Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. # Example: # additional_workspace_folders: # - ../sibling-package # - ../shared-lib -additional_workspace_folders: [] +ls_additional_workspace_folders: [] # list of additional paths to ignore in this project. # Same syntax as gitignore, so you can use * and **. diff --git a/src/solidlsp/initialize_params.py b/src/solidlsp/initialize_params.py index b8d81c0e..4c3a8912 100644 --- a/src/solidlsp/initialize_params.py +++ b/src/solidlsp/initialize_params.py @@ -50,9 +50,20 @@ class DefaultInitializeParamsBuilder(InitializeParamsBuilder): def _apply_updates(self): root_abs_path = self._ls.repository_root_path + self._set("processId", os.getpid()) self._set("rootPath", root_abs_path) self._set("rootUri", pathlib.Path(root_abs_path).as_uri()) self._set("clientInfo", {"name": "Serena"}) + if self._set_workspace_folders: - self._set("workspaceFolders", [self._create_workspace_folder_entry(root_abs_path)]) + abs_workspace_paths = self._ls.config.get_absolute_workspace_folders(root_abs_path) + log.info("Workspace folders: %s", abs_workspace_paths) + workspace_folders = [self._create_workspace_folder_entry(abs_path) for abs_path in abs_workspace_paths] + additional_abs_workspace_paths = [ + p for p in self._ls.config.get_absolute_additional_workspace_folders(root_abs_path) if p not in abs_workspace_paths + ] + if additional_abs_workspace_paths: + log.info("Additional workspace folders (not indexed by SolidLSP): %s", additional_abs_workspace_paths) + workspace_folders.extend([self._create_workspace_folder_entry(abs_path) for abs_path in additional_abs_workspace_paths]) + self._set("workspaceFolders", workspace_folders) diff --git a/src/solidlsp/language_servers/angular_language_server.py b/src/solidlsp/language_servers/angular_language_server.py index 03d7414e..5edd1dca 100644 --- a/src/solidlsp/language_servers/angular_language_server.py +++ b/src/solidlsp/language_servers/angular_language_server.py @@ -166,8 +166,8 @@ class AngularTypeScriptServer(TypeScriptLanguageServer): ) @override - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - params = super()._get_initialize_params(repository_absolute_path) + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() # Load @angular/language-service as a tsserver plugin via typescript-language-server's # initializationOptions.plugins API (the same API Vue uses for @vue/typescript-plugin). params["initializationOptions"] = { diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index c04730d8..8b65c822 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -120,8 +120,8 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer): return "typescript" @override - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - params = super()._get_initialize_params(repository_absolute_path) + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() params["initializationOptions"] = { "plugins": [ { diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 892843f5..0cab8c87 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -4,10 +4,9 @@ Provides TypeScript specific instantiation of the LanguageServer class. Contains import logging import os -import pathlib import shutil import threading -from typing import Any, cast +from typing import Any from overrides import override from sensai.util.logging import LogTime @@ -16,7 +15,6 @@ from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command @@ -224,11 +222,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): return "javascriptreact" return self.language_id - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize params for the TypeScript Language Server. - """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params = { "locale": "en", "capabilities": { @@ -258,12 +252,8 @@ class TypeScriptLanguageServer(SolidLanguageServer): "workDoneProgress": True, # Enables $/progress notifications for project loading }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": self._build_workspace_folders_param(repository_absolute_path), } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -366,7 +356,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): log.info("Starting TypeScript server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info( "Sending initialize request from LSP client to LSP server and awaiting response", diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index f5112c0f..a29ab7f5 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -121,8 +121,8 @@ class VueTypeScriptServer(TypeScriptLanguageServer): ) @override - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - params = super()._get_initialize_params(repository_absolute_path) + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() params["initializationOptions"] = { "plugins": [ diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index ab130be5..11443129 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -11,6 +11,7 @@ from collections import defaultdict from collections.abc import Callable, Hashable, Iterator, Sequence from contextlib import contextmanager from copy import copy +from dataclasses import dataclass from pathlib import Path, PurePath from time import monotonic, perf_counter, sleep from typing import Any, Self, Union, cast @@ -653,7 +654,7 @@ class SolidLanguageServer(ABC): Do not instantiate this class directly. Use `LanguageServer.create` method instead. - :param config: the global SolidLSP configuration. + :param config: the language server's configuration. :param repository_root_path: the root path of the repository. :param process_launch_info: (DEPRECATED: pass None and implement _create_dependency_provider instead) the command used to start the actual language server. @@ -663,12 +664,13 @@ class SolidLanguageServer(ABC): notification by default. If the language server uses multiple language identifiers, it must override the method `get_language_id_for_file` to provide the appropriate identifier for each type of file. + :param solidlsp_settings: the global SolidLSP settings :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming from this specific language server. This should be incremented by subclasses calling this constructor whenever the format of the raw document symbols changes (typically because the language server improves/fixes its output). """ - self._solidlsp_config = config + self.config = config self._solidlsp_settings = solidlsp_settings lang = self.get_language_enum_instance() self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) @@ -746,28 +748,13 @@ class SolidLanguageServer(ABC): self._has_waited_for_cross_file_references = False - # resolving additional workspace folders - self._additional_workspace_abs_paths: list[str] = [] - _seen: set[str] = set() - for additional_workspace_path in self._solidlsp_settings.additional_workspace_folders: - if not additional_workspace_path or additional_workspace_path == ".": - continue - if not os.path.isabs(additional_workspace_path): - additional_workspace_abs_path = os.path.realpath(os.path.join(self.repository_root_path, additional_workspace_path)) - else: - additional_workspace_abs_path = str(Path(additional_workspace_path).resolve()) - if not os.path.isdir(additional_workspace_abs_path): - log.error( - "additional_workspace_folders: skipping non-existent directory %s (resolved to %s)", - additional_workspace_abs_path, - additional_workspace_abs_path, - ) - continue - if additional_workspace_abs_path in _seen: - log.info("additional_workspace_folders: skipping duplicate %s", additional_workspace_path) - continue - _seen.add(additional_workspace_abs_path) - self._additional_workspace_abs_paths.append(additional_workspace_abs_path) + self._abs_workspace_folders_indexed = self.config.get_absolute_workspace_folders(self.repository_root_path) + self._abs_workspace_folders_additional = self.config.get_absolute_additional_workspace_folders(self.repository_root_path) + """ + additional workspace folders, which are passed to the language server in the initialization request + but which are not indexed by SolidLSP, i.e. not traversed for full symbol tree + """ + self._abs_workspace_folders_all = self._abs_workspace_folders_indexed + self._abs_workspace_folders_additional def _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ @@ -806,7 +793,7 @@ class SolidLanguageServer(ABC): language=self.language, determine_log_level=self._determine_log_level, logger=logging_fn, - start_independent_lsp_process=self._solidlsp_config.start_independent_lsp_process, + start_independent_lsp_process=self.config.start_independent_lsp_process, ) # --- diagnostics-related functions --- @@ -1208,6 +1195,48 @@ class SolidLanguageServer(ABC): """Check if a relative path traverses outside the workspace root via '..' components.""" return ".." in PurePath(relative_file_path).parts + @dataclass + class PathWorkspaceStatus: + """ + Represents the status of a path with respect to the language server's workspace folders. + """ + + ls: "SolidLanguageServer" + resolve_abs_path: Path + is_in_workspace_folder: bool + workspace_root: str | None = None + """ + the workspace root the path is within/relative to, if any + """ + + @classmethod + def from_abs_resolved_path(cls, path: Path, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": + """ + :param path: an absolute, resolved Path instance + :param ls: the language server instance + """ + for workspace in ls._abs_workspace_folders_all: + if path.is_relative_to(workspace): + return SolidLanguageServer.PathWorkspaceStatus( + ls=ls, is_in_workspace_folder=True, workspace_root=workspace, resolve_abs_path=path + ) + return SolidLanguageServer.PathWorkspaceStatus(ls=ls, is_in_workspace_folder=False, resolve_abs_path=path) + + @classmethod + def from_relative_path(cls, relative_path: str, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": + """ + :param relative_path: a relative path from the repository root + :param ls: the language server instance + """ + return cls.from_abs_resolved_path(pathlib.Path(ls.repository_root_path, relative_path).resolve(), ls) + + def check_within_workspace_or_raise(self): + if not self.is_in_workspace_folder: + raise ValueError( + f"Path {self.resolve_abs_path} is outside of configured workspaces. " + f"Configured workspaces: {self.ls._abs_workspace_folders_all}." + ) + def _resolve_file_uri(self, relative_file_path: str) -> str: """Construct a canonical file URI from a relative path. @@ -1217,36 +1246,9 @@ class SolidLanguageServer(ABC): p = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)) if self._path_contains_dots(relative_file_path): p = p.resolve() - is_outside_of_configured_workspaces = True - configured_workspaces = [*self._additional_workspace_abs_paths, self.repository_root_path] - for workspace in configured_workspaces: - if p.is_relative_to(workspace): - is_outside_of_configured_workspaces = False - break - if is_outside_of_configured_workspaces: - raise ValueError( - f"Path {relative_file_path} contains '..' segments and is outside of configured workspaces. " - f"Configured workspaces: {configured_workspaces}. Resolved path: {p}." - ) + self.PathWorkspaceStatus.from_abs_resolved_path(p, self).check_within_workspace_or_raise() return p.as_uri() - def _build_workspace_folders_param(self, repository_absolute_path: str) -> list[dict[str, str]]: - """Build the ``workspaceFolders`` list for LSP initialization. - - Returns a list containing the primary workspace folder followed by any - additional workspace folders configured in settings. - """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - folders: list[dict[str, str]] = [ - {"uri": root_uri, "name": os.path.basename(repository_absolute_path)}, - ] - for abs_folder in self._additional_workspace_abs_paths: - folder_uri = pathlib.Path(abs_folder).as_uri() - folders.append({"uri": folder_uri, "name": os.path.basename(abs_folder)}) - if len(folders) > 1: - log.info("LSP multi-root workspace: %d folders", len(folders)) - return folders - def _activate_additional_workspaces(self) -> None: """Open a representative file from each additional workspace folder to trigger project loading in the language server. @@ -1260,7 +1262,7 @@ class SolidLanguageServer(ABC): enable this feature; the default implementation raises ``NotImplementedError``. """ opened_count = 0 - for additional_workspace in self._additional_workspace_abs_paths: + for additional_workspace in self._abs_workspace_folders_additional: source_file = self._find_representative_source_file(additional_workspace) if source_file is None: log.warning("No source file found in additional workspace folder: %s", additional_workspace) @@ -2179,24 +2181,17 @@ class SolidLanguageServer(ABC): If a directory is passed, all files within this directory will be considered. :return: A list of root symbols representing the top-level packages/modules in the project. """ - if within_relative_path is not None: - within_abs_path = os.path.join(self.repository_root_path, within_relative_path) - if not os.path.exists(within_abs_path): - raise FileNotFoundError(f"File or directory not found: {within_abs_path}") - if os.path.isfile(within_abs_path): - if self.is_ignored_path(within_relative_path): - log.error("You passed a file explicitly, but it is ignored. This is probably an error. File: %s", within_relative_path) - return [] - else: - root_nodes = self.request_document_symbols(within_relative_path).root_symbols - return root_nodes # Helper function to recursively process directories - def process_directory(rel_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: - abs_dir_path = self.repository_root_path if rel_dir_path == "." else os.path.join(self.repository_root_path, rel_dir_path) + def process_directory(abs_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: abs_dir_path = os.path.realpath(abs_dir_path) - if self.is_ignored_path(str(Path(abs_dir_path).relative_to(self.repository_root_path))): + rel_dir_path: str | None + try: + rel_dir_path = str(Path(abs_dir_path).relative_to(self.repository_root_path)) + except ValueError: # not relative to repository root + rel_dir_path = None + if rel_dir_path and self.is_ignored_path(rel_dir_path): log.debug("Skipping directory: %s (because it should be ignored)", rel_dir_path) return [] @@ -2243,7 +2238,7 @@ class SolidLanguageServer(ABC): continue if os.path.isdir(contained_dir_or_file_abs_path): - child_symbols = process_directory(contained_dir_or_file_rel_path) + child_symbols = process_directory(contained_dir_or_file_abs_path) package_symbol["children"].extend(child_symbols) for child in child_symbols: child["parent"] = package_symbol @@ -2293,9 +2288,23 @@ class SolidLanguageServer(ABC): return result - # Start from the root or the specified directory - start_rel_path = within_relative_path or "." - return process_directory(start_rel_path) + if within_relative_path: + within_abs_path = os.path.join(self.repository_root_path, within_relative_path) + if not os.path.exists(within_abs_path): + raise FileNotFoundError(f"File or directory not found: {within_abs_path}") + if self.is_ignored_path(within_relative_path): + raise ValueError(f"Explicitly requested symbols in '{within_relative_path}' while the path is ignored") + if os.path.isfile(within_abs_path): + root_nodes = self.request_document_symbols(within_relative_path).root_symbols + return root_nodes + else: + self.PathWorkspaceStatus.from_abs_resolved_path(Path(within_abs_path).resolve(), self).check_within_workspace_or_raise() + return process_directory(within_abs_path) + else: + full_result = [] + for root in self.config.get_absolute_workspace_folders(self.repository_root_path): + full_result.extend(process_directory(root)) + return full_result @staticmethod def _get_range_from_file_content(file_content: str) -> ls_types.Range: diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 6be2fc80..c666a2df 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -2,15 +2,20 @@ Configuration objects for language servers """ +import logging +import os import re from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum +from pathlib import Path from typing import TYPE_CHECKING, Self if TYPE_CHECKING: from solidlsp import SolidLanguageServer +log = logging.getLogger(__name__) + class FilenameMatcher: def __init__(self, *file_extensions: str, case_sensitive: bool = True) -> None: @@ -826,13 +831,25 @@ class Language(str, Enum): raise ValueError(f"Unhandled language server class: {ls_class}") -@dataclass +@dataclass(frozen=True) class LanguageServerConfig: """ Configuration parameters """ code_language: Language + workspace_folders: list[str] = field(default_factory=lambda: ["."]) + """ + list of workspace folders to be used by the language server and to be fully indexed by SolidLSP. + Paths can either be absolute or relative to the project root. + These folders must be descendants of the project root. + """ + additional_workspace_folders: list[str] = field(default_factory=list) + """ + list of additional workspace folders to be passed to the language server, but which are not to be indexed by SolidLSP. + Paths can either be absolute or relative to the project root. + These folders can potentially be outside of the project root, e.g. for cross-package reference support. + """ trace_lsp_communication: bool = False start_independent_lsp_process: bool = True ignored_paths: list[str] = field(default_factory=list) @@ -845,3 +862,38 @@ class LanguageServerConfig: import inspect return cls(**{k: v for k, v in env.items() if k in inspect.signature(cls).parameters}) + + @staticmethod + def _absolute_workspace_folders(folders: list[str], project_root: str) -> list[str]: + abs_workspace_folders = [] + for path in folders: + if os.path.isabs(path): + abs_path = str(Path(path).resolve()) + else: + abs_path = os.path.realpath(os.path.join(project_root, path)) + if not os.path.exists(abs_path): + log.error("Workspace folder does not exist: %s; skipping", abs_path) + continue + if abs_path in abs_workspace_folders: + log.warning("Duplicate workspace folder: %s; skipping", abs_path) + continue + abs_workspace_folders.append(abs_path) + return abs_workspace_folders + + def get_absolute_workspace_folders(self, project_root: str) -> list[str]: + """ + Get the absolute paths of the workspace folders, resolving relative paths against the project root. + + :param project_root: The root path of the project + :return: List of absolute workspace folder paths + """ + return self._absolute_workspace_folders(self.workspace_folders, project_root) + + def get_absolute_additional_workspace_folders(self, project_root: str) -> list[str]: + """ + Get the absolute paths of the additional workspace folders, resolving relative paths against the project root. + + :param project_root: The root path of the project + :return: List of absolute additional workspace folder paths + """ + return self._absolute_workspace_folders(self.additional_workspace_folders, project_root) diff --git a/test/conftest.py b/test/conftest.py index a8cf3394..fc046273 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -66,6 +66,7 @@ def _create_ls( ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False, ls_specific_settings: dict[Language, dict[str, Any]] | None = None, + workspace_folders: list[str] | None = None, additional_workspace_folders: list[str] | None = None, solidlsp_dir: Path | None = None, ) -> SolidLanguageServer: @@ -79,6 +80,8 @@ def _create_ls( code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication, + workspace_folders=workspace_folders or ["."], + additional_workspace_folders=additional_workspace_folders or [], ) effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME) @@ -89,7 +92,6 @@ def _create_ls( solidlsp_dir=effective_solidlsp_dir, project_data_path=project_data_path, ls_specific_settings=ls_specific_settings or {}, - additional_workspace_folders=additional_workspace_folders or [], ), ) @@ -101,11 +103,19 @@ def start_ls_context( ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False, ls_specific_settings: dict[Language, dict[str, Any]] | None = None, + workspace_folders: list[str] | None = None, additional_workspace_folders: list[str] | None = None, solidlsp_dir: Path | None = None, ) -> Iterator[SolidLanguageServer]: ls = _create_ls( - language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, additional_workspace_folders, solidlsp_dir + language, + repo_path, + ignored_paths, + trace_lsp_communication, + ls_specific_settings, + workspace_folders, + additional_workspace_folders, + solidlsp_dir, ) log.info(f"Starting language server for {language} {repo_path}") with ls.start_server_context(): diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index cfa1d11d..fab8f215 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -1,9 +1,12 @@ import os +from collections.abc import Sequence import pytest from solidlsp import SolidLanguageServer -from test.conftest import PYTHON_LANGUAGE_BACKENDS +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import PYTHON_LANGUAGE_BACKENDS, start_ls_context class TestLanguageServerCommonFunctionality: @@ -41,3 +44,35 @@ class TestLanguageServerCommonFunctionality: finally: os.remove(file_path) + + def test_workspace_folders_affect_full_symbol_tree(self): + """ + Tests that workspace folder configurations are respected when requesting the full symbol tree. + """ + + def check(langsrv: SolidLanguageServer, present: Sequence[str] = (), absent: Sequence[str] = ()) -> None: + symbols = langsrv.request_full_symbol_tree() + for name in present: + assert SymbolUtils.symbol_tree_contains_name(symbols, name) + for name in absent: + assert not SymbolUtils.symbol_tree_contains_name(symbols, name) + + symbols_in_subfolder_test_repo = ["OuterClass", "UserService"] + symbols_in_subfolder_scripts = ["parse_args", "create_sample_users"] + all_symbols = symbols_in_subfolder_test_repo + symbols_in_subfolder_scripts + + with start_ls_context(language=Language.PYTHON, workspace_folders=["."]) as ls: + ls.request_full_symbol_tree() + check(ls, present=all_symbols) + + with start_ls_context(language=Language.PYTHON, workspace_folders=["./test_repo"]) as ls: + ls.request_full_symbol_tree() + check(ls, present=symbols_in_subfolder_test_repo, absent=symbols_in_subfolder_scripts) + + with start_ls_context(language=Language.PYTHON, workspace_folders=["./scripts"]) as ls: + ls.request_full_symbol_tree() + check(ls, present=symbols_in_subfolder_scripts, absent=symbols_in_subfolder_test_repo) + + # test that explicit requests for symbols in other workspace folders are rejected + with pytest.raises(ValueError, match="outside"): + ls.request_full_symbol_tree(within_relative_path="./test_repo") From 1c12156901624ead484cfdbb1f3eee3390e7193d Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 2 Jul 2026 23:41:56 +0200 Subject: [PATCH 085/170] SolidLSP: Apply InitializeParamsBuilder across all remaining language servers Convert every remaining SolidLanguageServer subclass from the old _get_initialize_params method to the new _create_base_initialize_params override, so common LSP initialize keys (processId, rootPath, rootUri, clientInfo, workspaceFolders) are provided centrally by the InitializeParamsBuilder rather than by each server. - Drop the per-server processId/rootPath/rootUri/workspaceFolders and all clientInfo entries (including the VS Code spoofs in rust_analyzer and sourcekit); these are now set centrally. - Preserve nested capabilities.workspace.workspaceFolders flags and any server-specific initializationOptions.workspaceFolders (e.g. kotlin, eclipse_jdtls). - omnisharp keeps loading its JSON template but no longer sets the centrally-managed keys. - Update _start_server call sites to self._create_initialize_params() and drop now-unnecessary cast()/type: ignore wrappers. angular, svelte and vue are intentionally left as-is: they already override _create_base_initialize_params for their TypeScript companion server, and their remaining _get_initialize_params drives a separate language server process. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../language_servers/ada_language_server.py | 16 +--- .../language_servers/al_language_server.py | 16 +--- .../ansible_language_server.py | 19 +---- .../language_servers/bash_language_server.py | 19 +---- .../language_servers/bsl_language_server.py | 20 ++--- .../language_servers/ccls_language_server.py | 22 +---- .../clangd_language_server.py | 21 +---- src/solidlsp/language_servers/clojure_lsp.py | 14 +-- .../crystal_language_server.py | 20 +---- .../csharp_language_server.py | 85 +++++++++---------- .../language_servers/cue_language_server.py | 16 +--- .../language_servers/dart_language_server.py | 20 +---- .../language_servers/eclipse_jdtls.py | 21 ++--- .../elixir_tools/elixir_tools.py | 17 +--- .../language_servers/elm_language_server.py | 20 +---- .../fortran_language_server.py | 20 +---- .../fsharp_language_server.py | 14 +-- .../language_servers/godot_language_server.py | 14 +-- src/solidlsp/language_servers/gopls.py | 20 +---- .../groovy_language_server.py | 23 +---- .../haskell_language_server.py | 20 +---- .../language_servers/haxe_language_server.py | 21 ++--- .../language_servers/hlsl_language_server.py | 16 +--- src/solidlsp/language_servers/intelephense.py | 19 +---- src/solidlsp/language_servers/jedi_server.py | 22 +---- .../language_servers/json_language_server.py | 19 +---- src/solidlsp/language_servers/julia_server.py | 19 +---- .../kotlin_language_server.py | 24 +----- .../language_servers/lean4_language_server.py | 21 +---- src/solidlsp/language_servers/lua_ls.py | 20 +---- src/solidlsp/language_servers/luau_lsp.py | 20 +---- src/solidlsp/language_servers/marksman.py | 23 ++--- .../matlab_language_server.py | 21 ++--- .../language_servers/msl_language_server.py | 14 +-- src/solidlsp/language_servers/nixd_ls.py | 18 +--- .../language_servers/ocaml_lsp_server.py | 20 +---- src/solidlsp/language_servers/omnisharp.py | 23 ++--- .../language_servers/pascal_server.py | 20 +---- .../language_servers/perl_language_server.py | 21 +---- src/solidlsp/language_servers/phpactor.py | 18 +--- src/solidlsp/language_servers/phpantom.py | 21 +---- .../powershell_language_server.py | 19 +---- .../language_servers/pyrefly_server.py | 17 ++-- .../language_servers/r_language_server.py | 20 +---- src/solidlsp/language_servers/regal_server.py | 22 +---- src/solidlsp/language_servers/ruby_lsp.py | 12 +-- .../language_servers/rust_analyzer.py | 20 +---- .../language_servers/scala_language_server.py | 13 +-- src/solidlsp/language_servers/solargraph.py | 25 ++---- .../solidity_language_server.py | 22 ++--- .../some_sass_language_server.py | 19 +---- .../language_servers/sourcekit_lsp.py | 21 +---- .../language_servers/systemverilog_server.py | 16 +--- src/solidlsp/language_servers/taplo_server.py | 20 +---- src/solidlsp/language_servers/terraform_ls.py | 21 +---- .../texlab_language_server.py | 21 +---- src/solidlsp/language_servers/ty_server.py | 17 +--- .../vscode_html_language_server.py | 19 +---- .../language_servers/vts_language_server.py | 19 +---- .../language_servers/yaml_language_server.py | 19 +---- src/solidlsp/language_servers/zls.py | 18 +--- .../solidlsp/csharp/test_omnisharp_startup.py | 35 -------- 62 files changed, 252 insertions(+), 1020 deletions(-) delete mode 100644 test/solidlsp/csharp/test_omnisharp_startup.py diff --git a/src/solidlsp/language_servers/ada_language_server.py b/src/solidlsp/language_servers/ada_language_server.py index bf015bd4..30040ec2 100644 --- a/src/solidlsp/language_servers/ada_language_server.py +++ b/src/solidlsp/language_servers/ada_language_server.py @@ -9,15 +9,12 @@ pragmas/aspects in source rather than file extension, so a single import logging import os -import pathlib import threading -from typing import cast from overrides import override 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 @@ -155,15 +152,9 @@ class AdaLanguageServer(SolidLanguageServer): def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the initialization params for the Ada Language Server.""" - repository_absolute_path = self.repository_root_path - root_uri = pathlib.Path(repository_absolute_path).as_uri() - result = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -188,9 +179,8 @@ class AdaLanguageServer(SolidLanguageServer): }, "initializationOptions": {}, "trace": "off", - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: """Start the Ada Language Server process and complete the LSP handshake.""" @@ -213,7 +203,7 @@ class AdaLanguageServer(SolidLanguageServer): log.info("Starting ada_language_server process") self.server.start() - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index f954dbc7..f67ee2ba 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -7,7 +7,6 @@ You can pass the following entries in ``ls_specific_settings["al"]``: import logging import os -import pathlib import platform import re import stat @@ -448,20 +447,12 @@ class ALLanguageServer(SolidLanguageServer): return None - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> dict: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the AL Language Server. """ - # Ensure we have an absolute path for URI generation - repository_path = pathlib.Path(repository_absolute_path).resolve() - root_uri = repository_path.as_uri() - # AL requires extensive capabilities based on VS Code trace initialize_params = { - "processId": os.getpid(), - "rootPath": str(repository_path), - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -512,7 +503,6 @@ class ALLanguageServer(SolidLanguageServer): }, }, "trace": "verbose", - "workspaceFolders": [{"uri": root_uri, "name": repository_path.name}], } return initialize_params @@ -556,12 +546,12 @@ class ALLanguageServer(SolidLanguageServer): self.server.start() # Send initialize request - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to AL LSP server and awaiting response") # Send initialize and wait for response - resp = self.server.send_request("initialize", initialize_params) + resp = self.server.send_request("initialize", dict(initialize_params)) if resp is None: raise RuntimeError("AL Language Server initialization failed - no response") diff --git a/src/solidlsp/language_servers/ansible_language_server.py b/src/solidlsp/language_servers/ansible_language_server.py index 3fb1a3b6..39edcdb1 100644 --- a/src/solidlsp/language_servers/ansible_language_server.py +++ b/src/solidlsp/language_servers/ansible_language_server.py @@ -6,7 +6,6 @@ Contains various configurations and settings specific to Ansible YAML files (pla import fnmatch import logging import os -import pathlib import shutil from typing import Any, ClassVar @@ -15,7 +14,6 @@ from overrides import override from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -217,14 +215,12 @@ class AnsibleLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the initialize params for the Ansible Language Server. Reads shortcut keys and the ``ansible_settings`` dict from ``_custom_settings`` to build ``initializationOptions``. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - # default ansible settings, populated from shortcut keys ansible_settings: dict[str, Any] = { "ansible": { @@ -282,18 +278,9 @@ class AnsibleLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": {"ansible": ansible_settings}, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Starts the Ansible Language Server, waits for the server to be ready.""" @@ -325,7 +312,7 @@ class AnsibleLanguageServer(SolidLanguageServer): log.info("Starting Ansible server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index 0338a5ef..5d52bbbc 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -5,7 +5,6 @@ Contains various configurations and settings specific to Bash scripting. import logging import os -import pathlib import shutil import threading @@ -19,7 +18,6 @@ from solidlsp.ls import ( ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -208,12 +206,10 @@ class BashLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "start"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Bash Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -237,17 +233,8 @@ class BashLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -283,7 +270,7 @@ class BashLanguageServer(SolidLanguageServer): log.info("Starting Bash server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/bsl_language_server.py b/src/solidlsp/language_servers/bsl_language_server.py index 98aaa0e1..b3fc8f89 100644 --- a/src/solidlsp/language_servers/bsl_language_server.py +++ b/src/solidlsp/language_servers/bsl_language_server.py @@ -14,7 +14,6 @@ You can configure the following options in ls_specific_settings (in serena_confi import logging import os -import pathlib import re import shutil import subprocess @@ -27,7 +26,6 @@ from solidlsp.ls import ( 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__) @@ -166,10 +164,8 @@ class BSLLanguageServer(SolidLanguageServer): _verify_java_available() return ["java", "-jar", core_path] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() - return { # type: ignore[return-value] + def _create_base_initialize_params(self) -> dict: + return { "locale": "en", "capabilities": { "textDocument": { @@ -179,9 +175,9 @@ class BSLLanguageServer(SolidLanguageServer): "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore + "symbolKind": {"valueSet": list(range(1, 27))}, }, - "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, # type: ignore + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, "workspace": { @@ -190,12 +186,6 @@ class BSLLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - {"uri": root_uri, "name": os.path.basename(repository_absolute_path)}, - ], } def _start_server(self) -> None: @@ -216,7 +206,7 @@ class BSLLanguageServer(SolidLanguageServer): log.info("Starting BSL language server process") self.server.start() - init_params = self._get_initialize_params(self.repository_root_path) + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) log.debug("BSL LSP initialize response: %s", init_response) diff --git a/src/solidlsp/language_servers/ccls_language_server.py b/src/solidlsp/language_servers/ccls_language_server.py index a27d80d6..1c7c1fa6 100644 --- a/src/solidlsp/language_servers/ccls_language_server.py +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -29,10 +29,8 @@ https://github.com/MaskRay/ccls """ import logging -import os -import pathlib import threading -from typing import Any, cast +from typing import Any from overrides import override @@ -43,7 +41,6 @@ from solidlsp.ls import ( 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__) @@ -105,12 +102,10 @@ class CCLS(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the ccls Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -123,18 +118,9 @@ class CCLS(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], # ccls supports initializationOptions but none are required for basic functionality } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -154,7 +140,7 @@ class CCLS(SolidLanguageServer): log.info("Starting ccls server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to ccls and awaiting response") self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index b5c5d40a..4db49125 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -2,10 +2,9 @@ import hashlib import json import logging import os -import pathlib import threading from collections.abc import Hashable -from typing import Any, cast +from typing import Any from overrides import override @@ -13,7 +12,6 @@ from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection, is_unreal_engine_project @@ -315,12 +313,10 @@ class ClangdLanguageServer(SolidLanguageServer): # which is required for finding cross-file references return [core_path, "--background-index"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the clangd Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -336,18 +332,9 @@ class ClangdLanguageServer(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -403,7 +390,7 @@ class ClangdLanguageServer(SolidLanguageServer): log.info("Starting Clangd server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index 1835ce99..2a8554d1 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -9,13 +9,11 @@ import re import shutil import subprocess import threading -from typing import cast from overrides import override 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 @@ -308,10 +306,8 @@ class ClojureLSP(SolidLanguageServer): log.info(f"clojure-lsp source-paths scanned from project descriptors: {scanned}") return scanned - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the init params for clojure-lsp.""" - repository_absolute_path = self.repository_root_path - root_uri = pathlib.Path(repository_absolute_path).as_uri() source_paths = self._resolve_source_paths() initialization_options: dict = {"dependency-scheme": "jar", "text-document-sync-kind": "incremental"} @@ -319,9 +315,6 @@ class ClojureLSP(SolidLanguageServer): initialization_options["source-paths"] = source_paths result = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -344,9 +337,8 @@ class ClojureLSP(SolidLanguageServer): }, "initializationOptions": initialization_options, "trace": "off", - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: def register_capability_handler(params: dict) -> None: @@ -389,7 +381,7 @@ class ClojureLSP(SolidLanguageServer): log.info("Starting clojure-lsp server process") self.server.start() - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/crystal_language_server.py b/src/solidlsp/language_servers/crystal_language_server.py index 75ce704f..2e04c995 100644 --- a/src/solidlsp/language_servers/crystal_language_server.py +++ b/src/solidlsp/language_servers/crystal_language_server.py @@ -3,14 +3,11 @@ Provides Crystal specific instantiation of the LanguageServer class using Crysta """ import logging -import os -import pathlib import shutil import time from solidlsp.ls import 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 @@ -83,12 +80,10 @@ class CrystalLanguageServer(SolidLanguageServer): log.info(f"Waiting {remaining_delay:.1f}s for Crystalline to compile the project") time.sleep(remaining_delay) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Return the initialize params for the Crystal language server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -119,17 +114,8 @@ class CrystalLanguageServer(SolidLanguageServer): "configuration": True, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start the Crystal language server process.""" @@ -150,7 +136,7 @@ class CrystalLanguageServer(SolidLanguageServer): log.info("Starting Crystal language server (Crystalline) process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 296acad0..1281c5a0 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -26,7 +26,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Hover from solidlsp.ls_utils import FileUtils, PathUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult +from solidlsp.lsp_protocol_handler.lsp_types import InitializeResult from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -486,60 +486,51 @@ class CSharpLanguageServer(SolidLanguageServer): except Exception as e: raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer. """ - root_uri = PathUtils.path_to_uri(self.repository_root_path) - root_name = os.path.basename(self.repository_root_path) - return cast( - InitializeParams, - { - "workspaceFolders": [{"uri": root_uri, "name": root_name}], - "processId": os.getpid(), - "rootPath": self.repository_root_path, - "rootUri": root_uri, - "capabilities": { - "window": { - "workDoneProgress": True, - "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, - "showDocument": {"support": True}, + return { + "capabilities": { + "window": { + "workDoneProgress": True, + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + }, + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, }, - "workspace": { - "applyEdit": True, - "workspaceEdit": {"documentChanges": True}, - "didChangeConfiguration": {"dynamicRegistration": True}, - "didChangeWatchedFiles": {"dynamicRegistration": True}, - "symbol": { - "dynamicRegistration": True, - "symbolKind": {"valueSet": list(range(1, 27))}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + "workDoneProgress": True, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, }, - "executeCommand": {"dynamicRegistration": True}, - "configuration": True, - "workspaceFolders": True, - "workDoneProgress": True, }, - "textDocument": { - "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, - "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, - "signatureHelp": { - "dynamicRegistration": True, - "signatureInformation": { - "documentationFormat": ["markdown", "plaintext"], - "parameterInformation": {"labelOffsetSupport": True}, - }, - }, - "definition": {"dynamicRegistration": True}, - "references": {"dynamicRegistration": True}, - "documentSymbol": { - "dynamicRegistration": True, - "symbolKind": {"valueSet": list(range(1, 27))}, - "hierarchicalDocumentSymbolSupport": True, - }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, }, }, }, - ) + } def _start_server(self) -> None: indexing_complete = threading.Event() @@ -672,7 +663,7 @@ class CSharpLanguageServer(SolidLanguageServer): raise SolidLSPException(f"Failed to start C# language server: {e}") # Send initialization - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request to language server") try: diff --git a/src/solidlsp/language_servers/cue_language_server.py b/src/solidlsp/language_servers/cue_language_server.py index b9a90a33..444c5f26 100644 --- a/src/solidlsp/language_servers/cue_language_server.py +++ b/src/solidlsp/language_servers/cue_language_server.py @@ -7,15 +7,12 @@ from __future__ import annotations import logging import os -import pathlib import threading -from typing import cast from overrides import override 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 @@ -184,15 +181,9 @@ class CueLanguageServer(SolidLanguageServer): # cue's LSP mode is activated via the `lsp` subcommand; it speaks LSP over stdio. return [core_path, "lsp"] - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the init params for ``cue lsp``.""" - repository_absolute_path = self.repository_root_path - root_uri = pathlib.Path(repository_absolute_path).as_uri() - result = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -221,9 +212,8 @@ class CueLanguageServer(SolidLanguageServer): }, "initializationOptions": {}, "trace": "off", - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: def register_capability_handler(params: dict) -> None: @@ -252,7 +242,7 @@ class CueLanguageServer(SolidLanguageServer): log.info("Starting cue lsp server process") self.server.start() - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index 800f69eb..59fd4327 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -1,8 +1,6 @@ import logging import os -import pathlib from collections.abc import Hashable -from typing import cast from overrides import override @@ -11,7 +9,6 @@ from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from ..ls_config import Language, LanguageServerConfig -from ..lsp_protocol_handler.lsp_types import InitializeParams from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) @@ -149,12 +146,10 @@ class DartLanguageServer(SolidLanguageServer): return f"{dart_executable_path} language-server --client-id multilspy.dart --client-version 1.2" - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Dart Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "capabilities": { "textDocument": { @@ -171,18 +166,9 @@ class DartLanguageServer(SolidLanguageServer): "allowOpenUri": False, }, "trace": "verbose", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": pathlib.Path(repository_absolute_path).as_uri(), - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -212,7 +198,7 @@ class DartLanguageServer(SolidLanguageServer): log.info("Starting dart-language-server server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.debug("Sending initialize request to dart-language-server") init_response = self.server.send_request("initialize", initialize_params) # type: ignore log.info(f"Received initialize response from dart-language-server: {init_response}") diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 4614d9fe..320f7ed1 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -25,7 +25,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -904,15 +904,13 @@ class EclipseJDTLS(SolidLanguageServer): log.info(f"Using bundled JRE for JDTLS: {java_home}") return {"syntaxserver": "false", "JAVA_HOME": java_home} - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize parameters for the EclipseJDTLS server. """ # Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available - if not os.path.isabs(repository_absolute_path): - repository_absolute_path = os.path.abspath(repository_absolute_path) - repo_uri = pathlib.Path(repository_absolute_path).as_uri() + repo_uri = pathlib.Path(self.repository_root_path).as_uri() # Load user's Maven and Gradle configuration paths from ls_specific_settings["java"] @@ -996,8 +994,6 @@ class EclipseJDTLS(SolidLanguageServer): initialize_params = { "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": pathlib.Path(repository_absolute_path).as_uri(), "capabilities": { "workspace": { "applyEdit": True, @@ -1290,13 +1286,6 @@ class EclipseJDTLS(SolidLanguageServer): }, }, "trace": "verbose", - "processId": os.getpid(), - "workspaceFolders": [ - { - "uri": repo_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] @@ -1324,7 +1313,7 @@ class EclipseJDTLS(SolidLanguageServer): if self.runtime_dependency_paths.gradle_path is not None: gradle_settings["home"] = self.runtime_dependency_paths.gradle_path gradle_settings["java"] = {"home": gradle_java_home if gradle_java_home is not None else self.runtime_dependency_paths.jre_path} - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -1377,7 +1366,7 @@ class EclipseJDTLS(SolidLanguageServer): log.info("Starting EclipseJDTLS server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 6005df89..d80e43e3 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -5,14 +5,13 @@ import stat import subprocess import threading from collections.abc import Hashable -from typing import Any, cast +from typing import Any from overrides import override from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -256,19 +255,12 @@ class ElixirTools(SolidLanguageServer): return None - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Expert Language Server. """ - # Ensure the path is absolute - abs_path = os.path.abspath(repository_absolute_path) - root_uri = pathlib.Path(abs_path).as_uri() initialize_params = { - "processId": os.getpid(), "locale": "en", - "rootPath": abs_path, - "rootUri": root_uri, "initializationOptions": { "mix_env": "dev", "mix_target": "host", @@ -319,10 +311,9 @@ class ElixirTools(SolidLanguageServer): "workDoneProgress": True, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start Expert server process""" @@ -381,7 +372,7 @@ class ElixirTools(SolidLanguageServer): log.debug("Starting Expert server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.debug("Sending initialize request to Expert") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index e5823811..bd488500 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -4,7 +4,6 @@ Provides Elm specific instantiation of the LanguageServer class. Contains variou import logging import os -import pathlib import shutil import threading @@ -14,7 +13,6 @@ from sensai.util.logging import LogTime from solidlsp import ls_types from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -131,13 +129,10 @@ class ElmLanguageServer(SolidLanguageServer): ) return [elm_ls_executable_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Elm Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params = { "locale": "en", "capabilities": { @@ -169,17 +164,8 @@ class ElmLanguageServer(SolidLanguageServer): "skipInstallPackageConfirmation": True, "onlyUpdateDiagnosticsOnSave": False, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """ @@ -204,7 +190,7 @@ class ElmLanguageServer(SolidLanguageServer): log.info("Starting Elm server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 39498971..134460ab 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -3,8 +3,6 @@ Fortran Language Server implementation using fortls. """ import logging -import os -import pathlib import re from overrides import override @@ -19,7 +17,6 @@ from solidlsp.ls import ( 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__) @@ -190,10 +187,8 @@ class FortranLanguageServer(SolidLanguageServer): version_setting_key="fortls_version", ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Initialize params for Fortran Language Server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -231,17 +226,8 @@ class FortranLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start Fortran Language Server process.""" @@ -264,7 +250,7 @@ class FortranLanguageServer(SolidLanguageServer): log.info("Starting Fortran Language Server (fortls) process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request to Fortran Language Server") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py index a8cd6847..4b4fc820 100644 --- a/src/solidlsp/language_servers/fsharp_language_server.py +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -4,7 +4,6 @@ Provides F# specific instantiation of the LanguageServer class. import logging import os -import pathlib import shutil import threading from pathlib import Path @@ -16,7 +15,6 @@ from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependenc from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -129,17 +127,11 @@ class FSharpLanguageServer(SolidLanguageServer): # FsAutoComplete uses --lsp flag for LSP mode return f"{fsautocomplete_path} --adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the F# Language Server. """ - root_uri = pathlib.Path(self.repository_root_path).as_uri() - initialize_params = { - "processId": os.getpid(), - "rootPath": self.repository_root_path, - "rootUri": root_uri, - "workspaceFolders": [{"name": "workspace", "uri": root_uri}], "capabilities": { "workspace": { "applyEdit": True, @@ -269,7 +261,7 @@ class FSharpLanguageServer(SolidLanguageServer): "trace": "off", } - return initialize_params # type: ignore + return initialize_params def _get_dotnet_root(self) -> str: """ @@ -362,7 +354,7 @@ class FSharpLanguageServer(SolidLanguageServer): raise SolidLSPException(f"Failed to start F# language server: {e}") # Send initialization - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request to F# language server") try: diff --git a/src/solidlsp/language_servers/godot_language_server.py b/src/solidlsp/language_servers/godot_language_server.py index 453f0f92..d0ea8c3f 100644 --- a/src/solidlsp/language_servers/godot_language_server.py +++ b/src/solidlsp/language_servers/godot_language_server.py @@ -8,13 +8,11 @@ The editor must be open with its built-in language server enabled (default). import logging import os -import pathlib from collections.abc import Callable from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_process import LanguageServerInterface, TCPConnectionInfo, TCPLanguageServer -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict from solidlsp.settings import SolidLSPSettings @@ -93,14 +91,8 @@ class GodotLanguageServer(SolidLanguageServer): request_timeout=request_timeout, ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: params = { - "processId": os.getpid(), - "rootUri": root_uri, - "rootPath": repository_absolute_path, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -125,7 +117,7 @@ class GodotLanguageServer(SolidLanguageServer): }, }, } - return params # type: ignore + return params def _start_server(self) -> None: def do_nothing(params: dict) -> None: @@ -143,7 +135,7 @@ class GodotLanguageServer(SolidLanguageServer): log.info("Connecting to Godot LSP at %s:%d", self._conn_info.host, self._conn_info.port) self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending LSP initialize request to Godot") self.server.send.initialize(initialize_params) self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 3c098178..44164927 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -2,16 +2,14 @@ import hashlib import json import logging import os -import pathlib import subprocess from collections.abc import Hashable -from typing import Any, cast +from typing import Any from overrides import override from solidlsp.ls import RawDocumentSymbol, 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 @@ -103,11 +101,10 @@ class Gopls(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", solidlsp_settings) self.request_id = 0 - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Go Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params: dict = { "locale": "en", "capabilities": { @@ -122,15 +119,6 @@ class Gopls(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } # Apply gopls-specific settings via initializationOptions @@ -149,7 +137,7 @@ class Gopls(SolidLanguageServer): log.debug("Applying gopls settings via initializationOptions: keys=%s", list(gopls_settings.keys())) initialize_params["initializationOptions"] = gopls_settings - return cast(InitializeParams, initialize_params) + return initialize_params def _validate_gopls_settings_dict(self, gopls_settings: object) -> dict: if not isinstance(gopls_settings, dict): @@ -233,7 +221,7 @@ class Gopls(SolidLanguageServer): log.info("Starting gopls server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py index 2370e128..f6530733 100644 --- a/src/solidlsp/language_servers/groovy_language_server.py +++ b/src/solidlsp/language_servers/groovy_language_server.py @@ -5,13 +5,11 @@ Provides Groovy specific instantiation of the LanguageServer class. Contains var import dataclasses import logging import os -import pathlib import shlex from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -244,19 +242,11 @@ class GroovyLanguageServer(SolidLanguageServer): " Ensure the JAR file is available at the configured path\n" ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Groovy Language Server. """ - if not os.path.isabs(repository_absolute_path): - repository_absolute_path = os.path.abspath(repository_absolute_path) - - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "clientInfo": {"name": "Serena Groovy Client", "version": "1.0.0"}, - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"dynamicRegistration": True, "didSave": True}, @@ -282,15 +272,8 @@ class GroovyLanguageServer(SolidLanguageServer): } }, }, - "processId": os.getpid(), - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -316,7 +299,7 @@ class GroovyLanguageServer(SolidLanguageServer): log.info("Starting Groovy server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/haskell_language_server.py b/src/solidlsp/language_servers/haskell_language_server.py index be3ed0db..c732c4b2 100644 --- a/src/solidlsp/language_servers/haskell_language_server.py +++ b/src/solidlsp/language_servers/haskell_language_server.py @@ -4,7 +4,6 @@ Provides Haskell specific instantiation of the LanguageServer class. Contains va import logging import os -import pathlib import shutil import time from typing import Any @@ -13,7 +12,6 @@ from overrides import override from solidlsp.ls import 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 @@ -107,14 +105,11 @@ class HaskellLanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["dist", "dist-newstyle", ".stack-work", ".cabal-sandbox"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Haskell Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", "capabilities": { "workspace": { @@ -299,17 +294,8 @@ class HaskellLanguageServer(SolidLanguageServer): } }, "trace": "verbose", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -364,7 +350,7 @@ class HaskellLanguageServer(SolidLanguageServer): log.info("Starting Haskell Language Server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py index 24ca6fe7..3d4a154c 100644 --- a/src/solidlsp/language_servers/haxe_language_server.py +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -22,7 +22,7 @@ from solidlsp.ls import ( from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Hover -from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity, InitializeParams +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -202,13 +202,11 @@ class HaxeLanguageServer(SolidLanguageServer): "dump", ] - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Return initialize params for the Haxe Language Server. displayArguments are resolved from user-configured buildFile or auto-discovered .hxml files. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - # 1. Check for user-configured .hxml path configured_build_file = self._custom_settings.get("buildFile") if configured_build_file: @@ -216,7 +214,7 @@ class HaxeLanguageServer(SolidLanguageServer): display_arguments = [configured_build_file] else: # 2. Auto-discover .hxml files recursively - display_arguments = self._discover_hxml_file(repository_absolute_path) + display_arguments = self._discover_hxml_file(self.repository_root_path) init_options: dict = {"displayArguments": display_arguments} rename_source_folders = self._custom_settings.get("renameSourceFolders") @@ -258,17 +256,8 @@ class HaxeLanguageServer(SolidLanguageServer): }, }, "initializationOptions": init_options, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params @staticmethod def _discover_hxml_file(repository_absolute_path: str) -> list[str]: @@ -377,7 +366,7 @@ class HaxeLanguageServer(SolidLanguageServer): log.info("Starting Haxe server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py index 839e0381..41b945ef 100644 --- a/src/solidlsp/language_servers/hlsl_language_server.py +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -5,9 +5,8 @@ Supports HLSL, GLSL, and WGSL shader file formats. import logging import os -import pathlib import shutil -from typing import Any, cast +from typing import Any from overrides import override @@ -17,7 +16,6 @@ from solidlsp.ls import ( 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 @@ -167,13 +165,8 @@ class HlslLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "locale": "en", "capabilities": { "textDocument": { @@ -207,9 +200,8 @@ class HlslLanguageServer(SolidLanguageServer): "configuration": True, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params @override def _start_server(self) -> None: @@ -237,7 +229,7 @@ class HlslLanguageServer(SolidLanguageServer): log.info("Starting shader-language-server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index 47212695..6f68c33e 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -4,7 +4,6 @@ Provides PHP specific instantiation of the LanguageServer class using Intelephen import logging import os -import pathlib import shutil from time import sleep @@ -13,7 +12,7 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink from solidlsp.settings import SolidLSPSettings from ..lsp_protocol_handler import lsp_types @@ -110,11 +109,10 @@ class Intelephense(SolidLanguageServer): def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialization params for the Intelephense Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -135,15 +133,6 @@ class Intelephense(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } initialization_options = {} # Add license key if provided via environment variable @@ -159,7 +148,7 @@ class Intelephense(SolidLanguageServer): initialization_options["intelephense.files.maxSize"] = max_file_size initialize_params["initializationOptions"] = initialization_options - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start Intelephense server process""" @@ -180,7 +169,7 @@ class Intelephense(SolidLanguageServer): log.info("Starting Intelephense server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/jedi_server.py b/src/solidlsp/language_servers/jedi_server.py index 49ca91a0..7ad3b227 100644 --- a/src/solidlsp/language_servers/jedi_server.py +++ b/src/solidlsp/language_servers/jedi_server.py @@ -3,16 +3,12 @@ Provides Python specific instantiation of the LanguageServer class. Contains var """ import logging -import os -import pathlib import threading -from typing import cast from overrides import override from solidlsp.ls import 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 @@ -40,18 +36,12 @@ class JediServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Jedi Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, # Note: this is not necessarily the minimal set of capabilities... "capabilities": { "workspace": { @@ -143,14 +133,8 @@ class JediServer(SolidLanguageServer): }, }, "trace": "verbose", - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -182,7 +166,7 @@ class JediServer(SolidLanguageServer): log.info("Starting jedi-language-server server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/json_language_server.py b/src/solidlsp/language_servers/json_language_server.py index f764418e..654f2cc4 100644 --- a/src/solidlsp/language_servers/json_language_server.py +++ b/src/solidlsp/language_servers/json_language_server.py @@ -5,14 +5,12 @@ Contains various configurations and settings specific to JSON files. import logging import os -import pathlib import shutil from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -101,12 +99,10 @@ class JsonLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the JSON Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -127,17 +123,8 @@ class JsonLanguageServer(SolidLanguageServer): "didChangeConfiguration": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -160,7 +147,7 @@ class JsonLanguageServer(SolidLanguageServer): log.info("Starting JSON server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index 7b7579b6..cde52982 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -1,6 +1,5 @@ import logging import os -import pathlib import platform import shutil import subprocess @@ -10,7 +9,7 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag, InitializeParams +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -136,15 +135,11 @@ class JuliaLanguageServer(SolidLanguageServer): # runs (standalone test invocations, CI cold start). return 30.0 - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Julia Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params: InitializeParams = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, + initialize_params: dict = { "capabilities": { # workspace.configuration MUST be true: LanguageServer.jl pulls all julia.lint.* # settings via workspace/configuration after initialize. Without it, the server @@ -164,12 +159,6 @@ class JuliaLanguageServer(SolidLanguageServer): "documentSymbol": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } return initialize_params @@ -202,7 +191,7 @@ class JuliaLanguageServer(SolidLanguageServer): log.info("Starting LanguageServer.jl server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request to Julia Language Server") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py index 3998dafa..45901ede 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -21,7 +21,6 @@ import os import pathlib import stat import threading -from typing import cast from overrides import override @@ -32,7 +31,6 @@ from solidlsp.ls import ( ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -185,20 +183,13 @@ class KotlinLanguageServer(SolidLanguageServer): env["JAVA_TOOL_OPTIONS"] = jvm_options return env - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Kotlin Language Server. """ - if not os.path.isabs(repository_absolute_path): - repository_absolute_path = os.path.abspath(repository_absolute_path) - - root_uri = pathlib.Path(repository_absolute_path).as_uri() + root_uri = pathlib.Path(self.repository_root_path).as_uri() initialize_params = { - "clientInfo": {"name": "Multilspy Kotlin Client", "version": "1.0.0"}, "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -429,15 +420,8 @@ class KotlinLanguageServer(SolidLanguageServer): }, }, "trace": "off", - "processId": os.getpid(), - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -503,7 +487,7 @@ class KotlinLanguageServer(SolidLanguageServer): log.info("Starting Kotlin server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/lean4_language_server.py b/src/solidlsp/language_servers/lean4_language_server.py index 961a65b6..4f4af40a 100644 --- a/src/solidlsp/language_servers/lean4_language_server.py +++ b/src/solidlsp/language_servers/lean4_language_server.py @@ -4,17 +4,13 @@ Uses the built-in Lean 4 language server (lean --server). """ import logging -import os -import pathlib import shutil import subprocess -from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -96,16 +92,11 @@ class Lean4LanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [".lake", "build"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Lean 4 Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -131,14 +122,8 @@ class Lean4LanguageServer(SolidLanguageServer): "didChangeConfiguration": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start the Lean 4 language server process.""" @@ -160,7 +145,7 @@ class Lean4LanguageServer(SolidLanguageServer): log.info("Starting Lean 4 language server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py index 3176e41f..7b31cc71 100644 --- a/src/solidlsp/language_servers/lua_ls.py +++ b/src/solidlsp/language_servers/lua_ls.py @@ -7,8 +7,6 @@ You can pass the following entries in ``ls_specific_settings["lua"]``: """ import logging -import os -import pathlib import platform import shutil from collections.abc import Hashable @@ -20,7 +18,6 @@ from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -220,12 +217,10 @@ class LuaLanguageServer(SolidLanguageServer): return original_name - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Lua Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -270,15 +265,6 @@ class LuaLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { # Lua Language Server specific options "runtime": { @@ -304,7 +290,7 @@ class LuaLanguageServer(SolidLanguageServer): }, }, } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start Lua Language Server process""" @@ -325,7 +311,7 @@ class LuaLanguageServer(SolidLanguageServer): log.info("Starting Lua Language Server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/luau_lsp.py b/src/solidlsp/language_servers/luau_lsp.py index f0cb06bf..a23daed7 100644 --- a/src/solidlsp/language_servers/luau_lsp.py +++ b/src/solidlsp/language_servers/luau_lsp.py @@ -20,8 +20,6 @@ See: https://github.com/JohnnyMorganz/luau-lsp """ import logging -import os -import pathlib import platform import shutil import threading @@ -32,7 +30,6 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -270,12 +267,10 @@ class LuauLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, None, "luau", solidlsp_settings) self.server_ready = threading.Event() - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Luau Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -322,20 +317,11 @@ class LuauLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], # luau-lsp initialization options # These can be overridden via .luaurc in the project root "initializationOptions": {}, } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start Luau Language Server process""" @@ -366,7 +352,7 @@ class LuauLanguageServer(SolidLanguageServer): log.info("Starting Luau Language Server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index 72d7268a..98696f53 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -5,7 +5,6 @@ Contains various configurations and settings specific to Markdown. import logging import os -import pathlib from collections.abc import Hashable from overrides import override @@ -19,7 +18,6 @@ from solidlsp.ls import ( ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -195,17 +193,12 @@ class Marksman(SolidLanguageServer): return document_symbols - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Marksman Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params: InitializeParams = { - "processId": os.getpid(), + initialize_params: dict = { "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -215,9 +208,9 @@ class Marksman(SolidLanguageServer): "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] + "symbolKind": {"valueSet": list(range(1, 27))}, }, - "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, # type: ignore[list-item] + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, }, "workspace": { @@ -226,12 +219,6 @@ class Marksman(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } return initialize_params @@ -256,7 +243,7 @@ class Marksman(SolidLanguageServer): log.info("Starting marksman server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to marksman server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py index 5a83a75f..d6694355 100644 --- a/src/solidlsp/language_servers/matlab_language_server.py +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -37,12 +37,12 @@ import pathlib import platform import shutil import threading -from typing import Any, cast +from typing import Any from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -361,10 +361,8 @@ class MatlabLanguageServer(SolidLanguageServer): "MATLAB_INSTALL_PATH": self.get_matlab_path(), } - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Return the initialize params for the MATLAB Language Server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -394,17 +392,8 @@ class MatlabLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start the MATLAB Language Server and wait for it to be ready.""" @@ -460,7 +449,7 @@ class MatlabLanguageServer(SolidLanguageServer): log.info("Starting MATLAB server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/msl_language_server.py b/src/solidlsp/language_servers/msl_language_server.py index e2596a05..e8c89c1c 100644 --- a/src/solidlsp/language_servers/msl_language_server.py +++ b/src/solidlsp/language_servers/msl_language_server.py @@ -8,7 +8,6 @@ and launched as a subprocess using the current Python interpreter. import logging import os -import pathlib import sys import threading @@ -16,7 +15,6 @@ from solidlsp.ls import ( 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 @@ -46,10 +44,8 @@ class MslLanguageServer(SolidLanguageServer): ) self.server_ready = threading.Event() - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the initialize params for the mSL Language Server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -69,12 +65,8 @@ class MslLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Starts the mSL Language Server.""" @@ -91,7 +83,7 @@ class MslLanguageServer(SolidLanguageServer): log.info("Starting mSL server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request to mSL LSP server") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/nixd_ls.py b/src/solidlsp/language_servers/nixd_ls.py index b697292c..eacd6364 100644 --- a/src/solidlsp/language_servers/nixd_ls.py +++ b/src/solidlsp/language_servers/nixd_ls.py @@ -6,8 +6,6 @@ Note: Windows is not supported as Nix itself doesn't support Windows natively. """ import logging -import os -import pathlib import platform import shutil import subprocess @@ -18,7 +16,6 @@ from overrides import override from solidlsp import ls_types from solidlsp.ls import DocumentSymbols, 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 @@ -250,12 +247,10 @@ class NixLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings) self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for nixd. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -318,15 +313,6 @@ class NixLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { # nixd specific options "nixpkgs": {"expr": "import <nixpkgs> { }"}, @@ -360,7 +346,7 @@ class NixLanguageServer(SolidLanguageServer): log.info("Starting nixd server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/ocaml_lsp_server.py b/src/solidlsp/language_servers/ocaml_lsp_server.py index b2e9511a..3a26e50e 100644 --- a/src/solidlsp/language_servers/ocaml_lsp_server.py +++ b/src/solidlsp/language_servers/ocaml_lsp_server.py @@ -5,7 +5,6 @@ Contains various configurations and settings specific to OCaml and Reason. import logging import os -import pathlib import platform import re import shutil @@ -18,7 +17,6 @@ from overrides import override from solidlsp.ls import 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 from solidlsp.util.subprocess_util import subprocess_kwargs @@ -321,19 +319,13 @@ class OcamlLanguageServer(SolidLanguageServer): """Define language-specific directories to ignore for OCaml projects.""" return super().is_ignored_dirname(dirname) or dirname in ["_build", "_opam", ".opam"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the OCaml Language Server. Supports both OCaml and Reason. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "workspaceFolders": True, @@ -376,14 +368,8 @@ class OcamlLanguageServer(SolidLanguageServer): }, }, "trace": "verbose", - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """ @@ -417,7 +403,7 @@ class OcamlLanguageServer(SolidLanguageServer): log.info("Starting OCaml LSP server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py index 58dda4ee..e64746e5 100644 --- a/src/solidlsp/language_servers/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -5,7 +5,6 @@ Provides C# specific instantiation of the LanguageServer class. Contains various import json import logging import os -import pathlib import threading from collections.abc import Iterable @@ -15,7 +14,6 @@ from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_utils import DotnetVersion, FileUtils, PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -127,8 +125,7 @@ class OmniSharp(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Omnisharp Language Server. """ @@ -137,18 +134,10 @@ class OmniSharp(SolidLanguageServer): del d["_description"] - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + # processId, rootPath, rootUri and workspaceFolders are set centrally by the InitializeParamsBuilder + del d["rootPath"] + del d["rootUri"] + del d["workspaceFolders"] return d @@ -402,7 +391,7 @@ class OmniSharp(SolidLanguageServer): log.info("Starting OmniSharp server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py index 1f1488e7..e27831fd 100644 --- a/src/solidlsp/language_servers/pascal_server.py +++ b/src/solidlsp/language_servers/pascal_server.py @@ -49,7 +49,6 @@ import hashlib import json import logging import os -import pathlib import platform import shutil import tarfile @@ -64,7 +63,6 @@ import zipfile from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, quote_windows_path from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -777,8 +775,7 @@ class PascalLanguageServer(SolidLanguageServer): log.info(f"Using pasls at: {pasls_executable_path}") return quote_windows_path(pasls_executable_path) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Pascal Language Server. @@ -788,8 +785,6 @@ class PascalLanguageServer(SolidLanguageServer): We only pass target OS/CPU in initializationOptions if explicitly set. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - # Build initializationOptions from environment variables # pasls reads these to configure CodeTools: # - PP: Path to FPC compiler executable @@ -881,18 +876,9 @@ class PascalLanguageServer(SolidLanguageServer): }, }, "initializationOptions": initialization_options, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -926,7 +912,7 @@ class PascalLanguageServer(SolidLanguageServer): log.info("Starting Pascal server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py index c2e40b39..9b785ded 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -5,8 +5,6 @@ Note: Windows is not supported as Nix itself doesn't support Windows natively. """ import logging -import os -import pathlib import subprocess import time from typing import Any @@ -16,7 +14,7 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams, InitializeParams +from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -112,17 +110,12 @@ class PerlLanguageServer(SolidLanguageServer): ) self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for Perl::LanguageServer. Based on the expected structure from Perl::LanguageServer::Methods::_rpcreq_initialize. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -138,15 +131,9 @@ class PerlLanguageServer(SolidLanguageServer): }, }, "initializationOptions": {}, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start Perl::LanguageServer process""" @@ -180,7 +167,7 @@ class PerlLanguageServer(SolidLanguageServer): log.info("Starting Perl::LanguageServer process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py index 181cb3ee..306374d9 100644 --- a/src/solidlsp/language_servers/phpactor.py +++ b/src/solidlsp/language_servers/phpactor.py @@ -4,7 +4,6 @@ Provides PHP specific instantiation of the LanguageServer class using Phpactor. import logging import os -import pathlib import re import shutil import stat @@ -15,7 +14,6 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -122,15 +120,11 @@ class PhpactorServer(SolidLanguageServer): def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialization params for the Phpactor Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -145,19 +139,13 @@ class PhpactorServer(SolidLanguageServer): "didChangeConfiguration": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { "language_server_phpstan.enabled": False, "language_server_psalm.enabled": False, "language_server_php_cs_fixer.enabled": False, }, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start Phpactor server process.""" @@ -178,7 +166,7 @@ class PhpactorServer(SolidLanguageServer): log.info("Starting Phpactor server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/phpantom.py b/src/solidlsp/language_servers/phpantom.py index 6fd42f0a..f0f11dd1 100644 --- a/src/solidlsp/language_servers/phpantom.py +++ b/src/solidlsp/language_servers/phpantom.py @@ -4,11 +4,9 @@ Provides PHP specific instantiation of the LanguageServer class using PHPantom. import logging import os -import pathlib import shutil import stat from time import sleep -from typing import cast from overrides import override @@ -22,7 +20,7 @@ from solidlsp.ls import ( from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler import lsp_types as protocol_lsp_types -from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -178,12 +176,10 @@ class PHPantomServer(SolidLanguageServer): def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialization params for the PHPantom Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - # declaring client capabilities initialize_params = { "locale": "en", @@ -213,17 +209,8 @@ class PHPantomServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start PHPantom server process.""" @@ -244,7 +231,7 @@ class PHPantomServer(SolidLanguageServer): log.info("Starting PHPantom server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() # negotiating server capabilities log.info("Sending initialize request from LSP client to LSP server and awaiting response") diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 1879ac02..99bac570 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -11,7 +11,6 @@ You can pass the following entries in ``ls_specific_settings["powershell"]``: import logging import os -import pathlib import platform import shutil import subprocess @@ -27,7 +26,6 @@ from solidlsp.ls import LSPConstants, RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -277,12 +275,10 @@ class PowerShellLanguageServer(SolidLanguageServer): return original_name - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the PowerShell Editor Services. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -326,17 +322,8 @@ class PowerShellLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """ @@ -375,7 +362,7 @@ class PowerShellLanguageServer(SolidLanguageServer): log.info("Starting PowerShell Editor Services process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/pyrefly_server.py b/src/solidlsp/language_servers/pyrefly_server.py index 3c5f26e2..b657895b 100644 --- a/src/solidlsp/language_servers/pyrefly_server.py +++ b/src/solidlsp/language_servers/pyrefly_server.py @@ -15,7 +15,7 @@ import pathlib import sys import threading import time -from typing import Any, cast +from typing import Any from typing_extensions import override @@ -23,7 +23,7 @@ from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, LSPErrorCodes +from solidlsp.lsp_protocol_handler.lsp_types import LSPErrorCodes from solidlsp.lsp_protocol_handler.server import LSPError, PayloadLike from solidlsp.settings import SolidLSPSettings @@ -171,17 +171,11 @@ class PyreflyLanguageServer(SolidLanguageServer): def _get_language_id_for_file(self, relative_file_path: str) -> str: return "python" - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the pyrefly language server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "clientInfo": {"name": "Serena", "version": "0.1.0"}, - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "window": { "workDoneProgress": True, @@ -216,9 +210,8 @@ class PyreflyLanguageServer(SolidLanguageServer): "publishDiagnostics": {"relatedInformation": True}, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -280,7 +273,7 @@ class PyreflyLanguageServer(SolidLanguageServer): self.server.start() # retry requests that pyrefly cancels while re-indexing the workspace in the background self._install_mutation_retry() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to pyrefly server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/r_language_server.py b/src/solidlsp/language_servers/r_language_server.py index 087eccca..8caeeed1 100644 --- a/src/solidlsp/language_servers/r_language_server.py +++ b/src/solidlsp/language_servers/r_language_server.py @@ -1,6 +1,4 @@ import logging -import os -import pathlib import subprocess from typing import Any @@ -8,7 +6,6 @@ from overrides import override from solidlsp.ls import 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 @@ -68,10 +65,8 @@ class RLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), "r", solidlsp_settings) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Initialize params for R Language Server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -107,17 +102,8 @@ class RLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start R Language Server process.""" @@ -140,7 +126,7 @@ class RLanguageServer(SolidLanguageServer): log.info("Starting R Language Server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info( "Sending initialize request to R Language Server", ) diff --git a/src/solidlsp/language_servers/regal_server.py b/src/solidlsp/language_servers/regal_server.py index 25870a04..c19f556e 100644 --- a/src/solidlsp/language_servers/regal_server.py +++ b/src/solidlsp/language_servers/regal_server.py @@ -1,16 +1,12 @@ """Regal Language Server implementation for Rego policy files.""" import logging -import os import shutil -from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_utils import PathUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -54,20 +50,14 @@ class RegalLanguageServer(SolidLanguageServer): solidlsp_settings, ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Regal Language Server. - :param repository_absolute_path: Absolute path to the repository :return: LSP initialization parameters """ - root_uri = PathUtils.path_to_uri(repository_absolute_path) initialize_params = { - "processId": os.getpid(), "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -89,14 +79,8 @@ class RegalLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "name": os.path.basename(repository_absolute_path), - "uri": root_uri, - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start Regal language server process and wait for initialization.""" @@ -117,7 +101,7 @@ class RegalLanguageServer(SolidLanguageServer): log.info("Starting Regal language server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info( "Sending initialize request from LSP client to LSP server and awaiting response", diff --git a/src/solidlsp/language_servers/ruby_lsp.py b/src/solidlsp/language_servers/ruby_lsp.py index fd72b289..0e66b16b 100644 --- a/src/solidlsp/language_servers/ruby_lsp.py +++ b/src/solidlsp/language_servers/ruby_lsp.py @@ -11,7 +11,6 @@ You can pass the following entries in ``ls_specific_settings["ruby"]``: import json import logging import os -import pathlib import shutil import subprocess import threading @@ -20,7 +19,7 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult +from solidlsp.lsp_protocol_handler.lsp_types import InitializeResult from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -329,16 +328,13 @@ class RubyLsp(SolidLanguageServer): return base_patterns - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns ruby-lsp specific initialization parameters. """ exclude_patterns = self._get_ruby_exclude_patterns(self.repository_root_path) initialize_params = { - "processId": os.getpid(), - "rootPath": self.repository_root_path, - "rootUri": pathlib.Path(self.repository_root_path).as_uri(), "capabilities": { "workspace": { "workspaceEdit": {"documentChanges": True}, @@ -371,7 +367,7 @@ class RubyLsp(SolidLanguageServer): }, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -437,7 +433,7 @@ class RubyLsp(SolidLanguageServer): log.info("Starting ruby-lsp server process") self.server.start() - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index 2e222b5a..65f29665 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -9,13 +9,11 @@ import platform import shutil import subprocess import threading -from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -210,14 +208,11 @@ class RustAnalyzer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["target"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Rust Analyzer Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "clientInfo": {"name": "Visual Studio Code - Insiders", "version": "1.82.0-insider"}, "locale": "en", "capabilities": { "workspace": { @@ -672,17 +667,8 @@ class RustAnalyzer(SolidLanguageServer): "workspace": {"symbol": {"search": {"kind": "only_types", "limit": 128, "scope": "workspace"}}}, }, "trace": "verbose", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -728,7 +714,7 @@ class RustAnalyzer(SolidLanguageServer): log.info("Starting RustAnalyzer server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index 563806cd..f360d1ce 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -4,7 +4,6 @@ Provides Scala specific instantiation of the LanguageServer class. Contains vari import logging import os -import pathlib import shutil import subprocess from enum import Enum @@ -14,7 +13,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -259,17 +257,12 @@ class ScalaLanguageServer(SolidLanguageServer): log.info("Bootstrapping metals finished.") return [metals_executable] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Scala Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "initializationOptions": { "compilerOptions": { "completionCommand": None, @@ -304,7 +297,7 @@ class ScalaLanguageServer(SolidLanguageServer): }, "capabilities": {"textDocument": {"documentSymbol": {"hierarchicalDocumentSymbolSupport": True}}}, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -315,7 +308,7 @@ class ScalaLanguageServer(SolidLanguageServer): log.info("Sending initialize request from LSP client to LSP server and awaiting response") - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() self.server.send.initialize(initialize_params) self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/solargraph.py b/src/solidlsp/language_servers/solargraph.py index 9379bc6d..b0ae3b00 100644 --- a/src/solidlsp/language_servers/solargraph.py +++ b/src/solidlsp/language_servers/solargraph.py @@ -6,7 +6,6 @@ Contains various configurations and settings specific to Ruby. import json import logging import os -import pathlib import re import shutil import subprocess @@ -16,7 +15,6 @@ from overrides import override from solidlsp.ls import 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 @@ -267,18 +265,13 @@ class Solargraph(SolidLanguageServer): return base_patterns - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Solargraph Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - exclude_patterns = Solargraph._get_ruby_exclude_patterns(repository_absolute_path) + exclude_patterns = Solargraph._get_ruby_exclude_patterns(self.repository_root_path) - initialize_params: InitializeParams = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, + initialize_params = { "initializationOptions": { "exclude": exclude_patterns, }, @@ -289,17 +282,11 @@ class Solargraph(SolidLanguageServer): "textDocument": { "documentSymbol": { "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] + "symbolKind": {"valueSet": list(range(1, 27))}, }, }, }, - "trace": "verbose", # type: ignore[typeddict-item] - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], + "trace": "verbose", } return initialize_params @@ -341,7 +328,7 @@ class Solargraph(SolidLanguageServer): log.info("Starting solargraph server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 7ee35953..515bd5ce 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -19,7 +19,6 @@ from solidlsp import ls_types from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPConstants, 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__) @@ -178,21 +177,10 @@ class SolidityLanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in {"artifacts", "cache", "typechain-types"} - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Return LSP InitializeParams for the Solidity language server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() - return { # type: ignore + return { "locale": "en", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "capabilities": { "textDocument": { "synchronization": { @@ -208,11 +196,11 @@ class SolidityLanguageServer(SolidLanguageServer): "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] + "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": { "dynamicRegistration": True, - "contentFormat": ["markdown", "plaintext"], # type: ignore[list-item] + "contentFormat": ["markdown", "plaintext"], }, "publishDiagnostics": {"relatedInformation": True}, }, @@ -263,7 +251,7 @@ class SolidityLanguageServer(SolidLanguageServer): log.info("Starting Solidity language server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.debug("Sending initialize request to Solidity language server") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from Solidity server: {init_response}") diff --git a/src/solidlsp/language_servers/some_sass_language_server.py b/src/solidlsp/language_servers/some_sass_language_server.py index 770b80ae..abce6596 100644 --- a/src/solidlsp/language_servers/some_sass_language_server.py +++ b/src/solidlsp/language_servers/some_sass_language_server.py @@ -25,7 +25,6 @@ from __future__ import annotations import logging import os -import pathlib import shutil import threading @@ -34,7 +33,6 @@ from overrides import override from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -157,9 +155,7 @@ class SomeSassLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params: dict = { "locale": "en", "capabilities": { @@ -183,17 +179,8 @@ class SomeSassLanguageServer(SolidLanguageServer): }, }, "initializationOptions": SOMESASS_INIT_OPTIONS, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params @staticmethod def _handle_workspace_configuration(params: dict) -> list[dict]: @@ -231,7 +218,7 @@ class SomeSassLanguageServer(SolidLanguageServer): log.info("Starting some-sass-language-server") self.server.start() - init_params = self._get_initialize_params(self.repository_root_path) + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) log.debug("Some Sass LS initialize response: %s", init_response) assert "completionProvider" in init_response["capabilities"], "Some Sass LSP did not advertise completionProvider" diff --git a/src/solidlsp/language_servers/sourcekit_lsp.py b/src/solidlsp/language_servers/sourcekit_lsp.py index 8d30daeb..e77ba74e 100644 --- a/src/solidlsp/language_servers/sourcekit_lsp.py +++ b/src/solidlsp/language_servers/sourcekit_lsp.py @@ -1,6 +1,5 @@ import logging import os -import pathlib import subprocess import time from collections.abc import Hashable @@ -11,7 +10,6 @@ from solidlsp import ls_types from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import SymbolKind -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -75,13 +73,10 @@ class SourceKitLSP(SolidLanguageServer): return original_name.split("(", 1)[0].strip() - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Swift Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params = { "capabilities": { "general": { @@ -295,7 +290,6 @@ class SourceKitLSP(SolidLanguageServer): "workspaceFolders": True, }, }, - "clientInfo": {"name": "Visual Studio Code", "version": "1.102.2"}, "initializationOptions": { "backgroundIndexing": True, "backgroundPreparationMode": "enabled", @@ -305,18 +299,9 @@ class SourceKitLSP(SolidLanguageServer): "workspace/peekDocuments": True, }, "locale": "en", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start sourcekit-lsp server process""" @@ -337,7 +322,7 @@ class SourceKitLSP(SolidLanguageServer): log.info("Starting sourcekit-lsp server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/systemverilog_server.py b/src/solidlsp/language_servers/systemverilog_server.py index bd1782c6..dd7f7b0c 100644 --- a/src/solidlsp/language_servers/systemverilog_server.py +++ b/src/solidlsp/language_servers/systemverilog_server.py @@ -4,14 +4,12 @@ SystemVerilog language server using verible-verilog-ls. import logging import os -import pathlib import shutil import subprocess -from typing import Any, cast +from typing import Any 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 @@ -150,13 +148,8 @@ class SystemVerilogLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "locale": "en", "capabilities": { "textDocument": { @@ -186,9 +179,8 @@ class SystemVerilogLanguageServer(SolidLanguageServer): "didChangeConfiguration": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: def do_nothing(params: Any) -> None: @@ -205,7 +197,7 @@ class SystemVerilogLanguageServer(SolidLanguageServer): log.info("Starting verible-verilog-ls process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py index ee9be952..69511d7b 100644 --- a/src/solidlsp/language_servers/taplo_server.py +++ b/src/solidlsp/language_servers/taplo_server.py @@ -18,8 +18,7 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_utils import FileUtils, PathUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.ls_utils import FileUtils from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -208,12 +207,10 @@ class TaploServer(SolidLanguageServer): f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked" ) from e - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Taplo Language Server. """ - root_uri = PathUtils.path_to_uri(repository_absolute_path) initialize_params = { "locale": "en", "capabilities": { @@ -236,17 +233,8 @@ class TaploServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -269,7 +257,7 @@ class TaploServer(SolidLanguageServer): log.info("Starting Taplo server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request to Taplo server") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index c654fd95..a298fee1 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -1,14 +1,12 @@ import logging import os import shutil -from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.ls_utils import PathUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.ls_utils import PlatformUtils from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -219,17 +217,12 @@ class TerraformLS(SolidLanguageServer): ) self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Terraform Language Server. """ - root_uri = PathUtils.path_to_uri(repository_absolute_path) result = { - "processId": os.getpid(), "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -243,14 +236,8 @@ class TerraformLS(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "workspaceFolders": [ - { - "name": os.path.basename(repository_absolute_path), - "uri": root_uri, - } - ], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: """Start terraform-ls server process""" @@ -271,7 +258,7 @@ class TerraformLS(SolidLanguageServer): log.info("Starting terraform-ls server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/texlab_language_server.py b/src/solidlsp/language_servers/texlab_language_server.py index ea95f6e6..91f0e09b 100644 --- a/src/solidlsp/language_servers/texlab_language_server.py +++ b/src/solidlsp/language_servers/texlab_language_server.py @@ -5,14 +5,12 @@ texlab is downloaded as a prebuilt binary from the latex-lsp/texlab GitHub relea import logging import os -from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_utils import PathUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.ls_utils import PlatformUtils from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -134,15 +132,10 @@ class TexlabLanguageServer(SolidLanguageServer): solidlsp_settings, ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the initialize params for the texlab Language Server.""" - root_uri = PathUtils.path_to_uri(repository_absolute_path) result = { - "processId": os.getpid(), "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -159,14 +152,8 @@ class TexlabLanguageServer(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "workspaceFolders": [ - { - "name": os.path.basename(repository_absolute_path), - "uri": root_uri, - } - ], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: """Start the texlab server process.""" @@ -187,7 +174,7 @@ class TexlabLanguageServer(SolidLanguageServer): log.info("Starting texlab server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to texlab and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/ty_server.py b/src/solidlsp/language_servers/ty_server.py index 1e0832f0..4ce67355 100644 --- a/src/solidlsp/language_servers/ty_server.py +++ b/src/solidlsp/language_servers/ty_server.py @@ -8,15 +8,11 @@ You can pass the following entries in ``ls_specific_settings["python_ty"]``: """ import logging -import os -import pathlib -from typing import cast from typing_extensions import override 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__) @@ -61,17 +57,11 @@ class TyLanguageServer(SolidLanguageServer): def _get_language_id_for_file(self, relative_file_path: str) -> str: return "python" - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Ty language server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "clientInfo": {"name": "Serena", "version": "0.1.0"}, - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "workspaceEdit": {"documentChanges": True}, @@ -101,9 +91,8 @@ class TyLanguageServer(SolidLanguageServer): "publishDiagnostics": {"relatedInformation": True}, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -130,7 +119,7 @@ class TyLanguageServer(SolidLanguageServer): # starting and initializing the server log.info("Starting ty language server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to ty server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/vscode_html_language_server.py b/src/solidlsp/language_servers/vscode_html_language_server.py index 7b32fc08..9b329bc3 100644 --- a/src/solidlsp/language_servers/vscode_html_language_server.py +++ b/src/solidlsp/language_servers/vscode_html_language_server.py @@ -20,7 +20,6 @@ from __future__ import annotations import logging import os -import pathlib import shutil import threading @@ -29,7 +28,6 @@ from overrides import override from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -112,9 +110,7 @@ class VsCodeHtmlLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params: dict = { "locale": "en", "capabilities": { @@ -142,17 +138,8 @@ class VsCodeHtmlLanguageServer(SolidLanguageServer): "handledSchemas": ["file"], "provideFormatter": False, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: def do_nothing(_params: dict) -> None: @@ -169,7 +156,7 @@ class VsCodeHtmlLanguageServer(SolidLanguageServer): log.info("Starting vscode-html-language-server") self.server.start() - init_params = self._get_initialize_params(self.repository_root_path) + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) log.debug("HTML LS initialize response: %s", init_response) # Sanity-check key capabilities; HTML LSP always provides documentSymbol + completion diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index 5525809b..23f8fe76 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -6,17 +6,14 @@ which provides TypeScript language server functionality via VSCode's TypeScript import logging import os -import pathlib import shutil import threading -from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -141,14 +138,13 @@ class VtsLanguageServer(SolidLanguageServer): 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: + def _create_base_initialize_params(self) -> dict: """ 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: dict = { "locale": "en", "capabilities": { @@ -172,22 +168,13 @@ class VtsLanguageServer(SolidLanguageServer): "configuration": True, # This might be needed for vtsls }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } 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) + return initialize_params def _start_server(self) -> None: """ @@ -244,7 +231,7 @@ class VtsLanguageServer(SolidLanguageServer): log.info("Starting VTS server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py index b52c760e..b229f15f 100644 --- a/src/solidlsp/language_servers/yaml_language_server.py +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -5,14 +5,12 @@ Contains various configurations and settings specific to YAML files. import logging import os -import pathlib import shutil from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -114,12 +112,10 @@ class YamlLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the YAML Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -142,15 +138,6 @@ class YamlLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { "yaml": { "schemaStore": {"enable": True, "url": "https://www.schemastore.org/api/json/catalog.json"}, @@ -161,7 +148,7 @@ class YamlLanguageServer(SolidLanguageServer): } }, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -184,7 +171,7 @@ class YamlLanguageServer(SolidLanguageServer): log.info("Starting YAML server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py index cb5652fc..c05ec371 100644 --- a/src/solidlsp/language_servers/zls.py +++ b/src/solidlsp/language_servers/zls.py @@ -13,7 +13,6 @@ from overrides import override from solidlsp.ls import 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 @@ -100,12 +99,10 @@ class ZigLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), "zig", solidlsp_settings) self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Zig Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -140,15 +137,6 @@ class ZigLanguageServer(SolidLanguageServer): "configuration": True, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { # ZLS specific options based on schema.json # Critical paths for ZLS to understand the project @@ -178,7 +166,7 @@ class ZigLanguageServer(SolidLanguageServer): "inlay_hints_hide_redundant_param_names_last_token": False, }, } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start ZLS server process""" @@ -199,7 +187,7 @@ class ZigLanguageServer(SolidLanguageServer): log.info("Starting ZLS server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/test/solidlsp/csharp/test_omnisharp_startup.py b/test/solidlsp/csharp/test_omnisharp_startup.py deleted file mode 100644 index 74b9df34..00000000 --- a/test/solidlsp/csharp/test_omnisharp_startup.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from solidlsp.language_servers.omnisharp import OmniSharp - - -class _NoWaitEvent: - def is_set(self) -> bool: - return False - - def wait(self, timeout: float | None = None) -> bool: - raise AssertionError("OmniSharp startup must not wait for dynamic capability registration") - - -@pytest.mark.csharp -def test_omnisharp_startup_does_not_wait_for_capability_registration(tmp_path: Path) -> None: - omnisharp = OmniSharp.__new__(OmniSharp) - omnisharp.repository_root_path = str(tmp_path) - omnisharp.server = SimpleNamespace( - send=SimpleNamespace(initialize=lambda _params: {"capabilities": {}}), - notify=SimpleNamespace(initialized=lambda _params: None, workspace_did_change_configuration=lambda _params: None), - on_request=lambda *_args: None, - on_notification=lambda *_args: None, - start=lambda: None, - ) - omnisharp.server_ready = _NoWaitEvent() - omnisharp.definition_available = _NoWaitEvent() - omnisharp.references_available = _NoWaitEvent() - omnisharp.completions_available = _NoWaitEvent() - - omnisharp._start_server() From 05181b62e5fb3852a40bfcd72beaa65e1f9efef4 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 3 Jul 2026 00:03:32 +0200 Subject: [PATCH 086/170] Docs: Update add-language guide for _create_base_initialize_params pattern Document the new LSP initialization pattern in the "adding new language support" memory: override _create_base_initialize_params (server-specific keys only), let the InitializeParamsBuilder supply the common keys, and call _create_initialize_params() in _start_server. Includes notes on builder-resolved workspace folders, nested initializationOptions folder lists, and opting out via set_workspace_folders=False. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../adding_new_language_support_guide.md | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index 2ac65b31..6eeed48e 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -67,31 +67,45 @@ You should look at at least one existing implementation of each base class to un ### 1.2 LSP Initialization -Override initialization methods if needed: +Override `_create_base_initialize_params` to provide server-specific initialization +parameters. The common keys — `processId`, `rootPath`, `rootUri`, `clientInfo` and +`workspaceFolders` — are set centrally by the `InitializeParamsBuilder` (see +`src/solidlsp/initialize_params.py`), so your override MUST NOT set them. Just return +the server-specific settings (typically `capabilities` and `initializationOptions`): ```python -def _get_initialize_params(self) -> InitializeParams: - """Return language-specific initialization parameters.""" +def _create_base_initialize_params(self) -> dict: + """Return language-specific initialization parameters (server-specific keys only).""" return { - "processId": os.getpid(), - "rootUri": PathUtils.path_to_uri(self.repository_root_path), "capabilities": { # Language-specific capabilities - } + }, + # "initializationOptions": {...}, # if the server needs them } def _start_server(self): """Start the language server with custom handlers.""" # Set up notification handlers self.server.on_notification("window/logMessage", self._handle_log_message) - - # Start server and initialize + + # Start server and initialize. Do NOT call _create_base_initialize_params directly; + # _create_initialize_params() wraps it with the builder to add the common keys. self.server.start() - init_response = self.server.send.initialize(self._get_initialize_params()) - + init_response = self.server.send.initialize(self._create_initialize_params()) + self.server.notify.initialized({}) ``` +Notes: +- The builder resolves `workspaceFolders` from the language server config (indexed + folders + `ls_additional_workspace_folders`); don't build the folder list yourself. +- To send a folder list nested inside `initializationOptions` (some servers, e.g. + `EclipseJDTLS`/`KotlinLanguageServer`, need this), set it explicitly there — only the + *top-level* `workspaceFolders` is builder-managed. +- To suppress the top-level `workspaceFolders` entirely, override + `_create_initialize_params_builder` and construct `DefaultInitializeParamsBuilder` + with `set_workspace_folders=False`. + After `_start_server` returns, the language server should be fully operational. If the server requires that one waits for certain notifications or responses before being ready, implement that logic here. For an example, see `EclipseJDTLS._start_server`. From 81b1bc804d95d67ed14e27b8561f56e4bedb1594 Mon Sep 17 00:00:00 2001 From: bymebyu <bymebyu@naver.com> Date: Fri, 3 Jul 2026 18:38:53 +0900 Subject: [PATCH 087/170] TypeScript/VTS: event-based indexing wait and no automatic typing acquisition (#1639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * TypeScript/VTS: event-based indexing wait, no automatic typing acquisition - Set disableAutomaticTypingAcquisition in initializationOptions (TS and VTS) so tsserver does not download type packages from the network during language server initialization. - Replace the fixed 2-second wait before cross-file reference requests with $/progress-based indexing tracking: a pre-open hook arms the tracking (expect_indexing) before didOpen, then the request waits until indexing progress drains (wait_for_indexing), bounded by a configurable 'indexing_timeout' (ls_specific_settings, default 30s). If tsserver does not start reporting progress within a 2-second grace period (the previous fixed wait), the request proceeds — so the fast path is never slower than before, while slow indexing is actually awaited instead of guessed at. --- CHANGELOG.md | 3 ++ docs/02-usage/050_configuration.md | 1 + .../typescript_language_server.py | 46 +++++++++++++++++-- .../language_servers/vts_language_server.py | 5 ++ src/solidlsp/ls.py | 11 +++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 831b4824..0c34aae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ Status of the `main` branch. Changes prior to the next official version change w - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. Enables Yarn PnP setups with `typescript.tsdk` pointing at the Yarn-generated SDK. + - TypeScript/VTS: disable automatic typing acquisition during initialization (no network + downloads at startup) and replace the fixed 2-second cross-file reference wait with + event-based `$/progress` indexing tracking (configurable `indexing_timeout`, default 30s) - C#: minor fixes in Omnisharp and Roslyn that prevented startup on some systems #1617 - `SvelteLanguageServer`: Fix diagnostics requests for TypeScript/JavaScript files incorrectly being processed by the Svelte LS instead of the TypeScript LS. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index c69faaaf..dd8aa138 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1000,6 +1000,7 @@ Supported settings: | `typescript_version` | `5.9.3` | Override the bundled `typescript` npm package version Serena installs when `ls_path` is not set. | | `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. | | `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | +| `indexing_timeout` | `30.0` | Timeout in seconds for waiting on tsserver's `$/progress` project-indexing signal (both at startup and before the first cross-file reference query). If indexing does not complete within this window, Serena logs a warning and proceeds anyway. Increase it for very large projects. | TypeScript supports [additional workspace folders](additional-workspace-folders) for cross-package reference discovery. Configure `additional_workspace_folders` in `project.yml` to enable this feature. diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 892843f5..e056e5de 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -7,6 +7,7 @@ import os import pathlib import shutil import threading +import time from typing import Any, cast from overrides import override @@ -73,6 +74,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): You can pass the following entries in ls_specific_settings["typescript"]: - typescript_version: Version of TypeScript to install (default: "5.9.3") - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") + - indexing_timeout: float, timeout in seconds for project indexing (default: 30.0) """ @classmethod @@ -81,7 +83,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): # Safety timeout for $/progress-based indexing wait. Normally the event fires # well within this window; the timeout is only hit if the server never sends progress. - INDEXING_PROGRESS_TIMEOUT = 15.0 if os.name == "nt" else 10.0 + INDEXING_PROGRESS_TIMEOUT = 30.0 def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ @@ -231,6 +233,15 @@ class TypeScriptLanguageServer(SolidLanguageServer): root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", + # Disable Automatic Type Acquisition (ATA): with ATA enabled, tsserver fetches + # @types/* packages from npm in the background during indexing, which makes startup + # slow, network-dependent, and nondeterministic (and can hang on offline/locked-down + # machines). Serena relies on the types already installed in the project instead. + "initializationOptions": { + "preferences": { + "disableAutomaticTypingAcquisition": True, + }, + }, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -393,13 +404,14 @@ class TypeScriptLanguageServer(SolidLanguageServer): # typescript-language-server may send $/progress for "Initializing JS/TS # language features…" after initialized. If no progress is sent, # _indexing_complete stays SET and wait() returns immediately. + indexing_timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT) log.info("Waiting for TypeScript project indexing to complete (if async)...") - if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT): + if self.wait_for_indexing(timeout=indexing_timeout): log.info("TypeScript project indexing complete") else: log.warning( "TypeScript project indexing did not complete within %.0fs; proceeding anyway", - self.INDEXING_PROGRESS_TIMEOUT, + indexing_timeout, ) self._activate_additional_workspaces() @@ -456,8 +468,32 @@ class TypeScriptLanguageServer(SolidLanguageServer): return self._published_diagnostics_timeout @override - def _get_wait_time_for_cross_file_referencing(self) -> float: - return 2 + def _pre_open_for_cross_file_references(self) -> None: + if not self._has_waited_for_cross_file_references: + self.expect_indexing() + + #: how long to wait for tsserver to *start* reporting indexing progress after didOpen; + #: matches the fixed wait the base implementation previously used + _INDEXING_START_GRACE_S = 2.0 + + @override + def _wait_for_cross_file_references_if_needed(self) -> None: + if self._has_waited_for_cross_file_references: + return + # Give tsserver a short grace period to start reporting $/progress after the file + # was opened. If progress starts, wait until it drains (bounded by indexing_timeout); + # if it never starts, tsserver considers the project loaded and we proceed after the + # grace period — which is no worse than the previous fixed 2-second wait. + grace_deadline = time.monotonic() + self._INDEXING_START_GRACE_S + while time.monotonic() < grace_deadline and not self._indexing_complete.is_set() and not self._active_progress_tokens: + time.sleep(0.05) + if self._active_progress_tokens: + timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT) + if self.wait_for_indexing(timeout=timeout): + log.info("TypeScript cross-file indexing complete") + else: + log.warning("TypeScript cross-file indexing did not complete within %.0fs; proceeding", timeout) + self._has_waited_for_cross_file_references = True @override def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index 5525809b..6cd092bb 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -151,6 +151,11 @@ class VtsLanguageServer(SolidLanguageServer): root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params: dict = { "locale": "en", + "initializationOptions": { + "preferences": { + "disableAutomaticTypingAcquisition": True, + }, + }, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index a769a3b3..af3e5f07 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1582,6 +1582,9 @@ class SolidLanguageServer(ABC): self._ensure_server_started() t0 = perf_counter() if _debug_enabled else None + # arm indexing tracking before didOpen so that the subsequent wait can + # observe the indexing progress triggered by opening the file + self.language_server._pre_open_for_cross_file_references() with self.language_server.open_file(self.relative_file_path): self.language_server._wait_for_cross_file_references_if_needed() try: @@ -1733,6 +1736,14 @@ class SolidLanguageServer(ABC): }, ) + def _pre_open_for_cross_file_references(self) -> None: + """Called just before open_file() for requests that may need cross-file indexing. + + Override to pre-arm async indexing tracking before sending didOpen (e.g. clear + a threading.Event so a subsequent wait for indexing blocks correctly). + The base implementation is a no-op. + """ + def _wait_for_cross_file_references_if_needed(self) -> None: if not self._has_waited_for_cross_file_references: # Some LS require waiting for a while before they can return accurate cross-file results. From 9157e256713438aa4a8ba95c18ac229863d879a3 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 3 Jul 2026 12:25:18 +0200 Subject: [PATCH 088/170] Improve docs --- docs/02-usage/040_workflow.md | 12 ++++++------ docs/02-usage/050_configuration.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index b95c6f90..f704b262 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -13,7 +13,7 @@ setting up a project with Serena typically involves the following steps: 4. **Working on coding tasks**: Using Serena to help you with actual coding tasks in the project (project-creation-indexing)= -## Project Creation & Indexing +## Project Creation Project creation is the process of defining fundamental project settings that are relevant to Serena's operation. @@ -38,7 +38,7 @@ To explicitly create a project, use the following command while in the project d * You can immediately index the project after creation with `--index`. (project-config)= -#### Project Configuration +## Project Configuration After creation, you can adjust the project settings in the generated `.serena/project.yml` file within the project directory. @@ -61,17 +61,17 @@ For detailed information on the parameters and possible settings, see the [template file](https://github.com/oraios/serena/blob/main/src/serena/resources/project.template.yml). :::{note} -Many settings in project.yml *extend* or *override* settings in the global configuration file `serena_config.yml`. +Many settings in project.yml *extend* or *override* settings in [Serena's global configuration](global-config). So use the project configuration specifically for aspects that apply only to the particular project. ::: -**Local Overrides**. The project.yml file is intended to be versioned together with the project. +**Local Overrides**. The `project.yml` file is intended to be versioned together with the project. You can specify local overrides for the settings in a `project.local.yml` file in the same directory (which, by default, is ignored by git). Any keys defined therein will override the respective key in `project.yml`. (indexing)= -### Indexing +## Indexing :::{note} Indexing is not a relevant operation when using the JetBrains plugin, as indexing is handled by the IDE. @@ -83,7 +83,7 @@ that requires symbol information. While in the project directory, run this command: - <serena> project index + serena project index Indexing has to be called only once. During regular usage, Serena will automatically update the index whenever files change. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 3429de33..cd722158 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -7,7 +7,7 @@ You can disable tools, change Serena's fundamental instructions (what we denote as the `system_prompt`), adjust the output of tools that just provide a prompt, and even adjust tool descriptions. -Serena is configured in using a multi-layered approach: +Serena is configured using a multi-layered approach: * **global configuration** (`serena_config.yml`, see below) * **project configuration** (`project.yml`, see [Project Configuration](project-config)) From f00d3cfa19f421b787877a92a0b64efe256081f7 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 3 Jul 2026 12:40:26 +0200 Subject: [PATCH 089/170] Add docstrings --- src/solidlsp/ls.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 65793bec..ddb238c5 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -279,6 +279,11 @@ class LanguageServerDependencyProvider(ABC): """ def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + """ + :param custom_settings: the (user-provided) language server-specific settings + :param ls_resources_dir: the directory in which data for the language-server shall be stored + (this is already specific to the concrete language server, i.e. no further subdirectory is needed) + """ self._custom_settings = custom_settings self._ls_resources_dir = ls_resources_dir @@ -674,6 +679,9 @@ class SolidLanguageServer(ABC): self._solidlsp_settings = solidlsp_settings lang = self.get_language_enum_instance() self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) + """ + the (user-provided) language server-specific settings + """ self._ls_resources_dir = self.ls_resources_dir(solidlsp_settings) log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}") self._encoding = config.encoding From 352b3d3fed7d4bb3201ad5c0b0f5bb1794945f7e Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 3 Jul 2026 12:42:07 +0200 Subject: [PATCH 090/170] Allow to overrride entries of initializationOptions via LS-specific settings --- docs/02-usage/050_configuration.md | 30 ++++++++++++++++++++++++++++++ src/solidlsp/initialize_params.py | 13 +++++++++++++ src/solidlsp/ls.py | 7 +++++++ 3 files changed, 50 insertions(+) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index cd722158..03f44761 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -250,6 +250,36 @@ Common examples include: `ansible`, `bash`, `bsl`, `clojure`, `cpp`, `cpp_ccls`, If `ls_path` is set, Serena's managed download or install is bypassed for that language server. In that case, any server-specific version or registry settings do not apply. +(override-init-options)= +#### Overriding Language Server Initialization Options + +When Serena starts a language server, it sends a set of `initializationOptions` as part of the +Language Server Protocol `initialize` request. These options are constructed internally and are +tailored to each language server. In some cases, you may want to override or extend these options, +e.g. to enable a feature or to adjust a behavior that is specific to your setup. + +Under the key `initializationOptions` within a language's `ls_specific_settings`, you can provide a +dictionary of options that is applied on top of the internally constructed `initializationOptions`. +The values are combined at the top level only: for each top-level key you define, your value +replaces the original value for that key exactly as given (there is no recursive/deep merge of +nested dictionaries). Internally constructed keys that you do not define are left unchanged. + +* If Serena constructs `initializationOptions` for the language server, each top-level key you + provide replaces the internally constructed value for that same key, while all other internally + constructed keys are retained. +* If Serena does not construct any `initializationOptions` for the language server, your custom + options are used as-is. + +Example: + +```yaml +ls_specific_settings: + <language>: + initializationOptions: + someFeature: + enabled: true +``` + #### AL Serena uses the AL language server bundled in the Microsoft Dynamics 365 Business Central VS Code extension. diff --git a/src/solidlsp/initialize_params.py b/src/solidlsp/initialize_params.py index 4c3a8912..90c5b4db 100644 --- a/src/solidlsp/initialize_params.py +++ b/src/solidlsp/initialize_params.py @@ -67,3 +67,16 @@ class DefaultInitializeParamsBuilder(InitializeParamsBuilder): log.info("Additional workspace folders (not indexed by SolidLSP): %s", additional_abs_workspace_paths) workspace_folders.extend([self._create_workspace_folder_entry(abs_path) for abs_path in additional_abs_workspace_paths]) self._set("workspaceFolders", workspace_folders) + + init_options_key = "initializationOptions" + custom_init_options = self._ls.custom_settings.get(init_options_key, None) + if custom_init_options: + if not isinstance(custom_init_options, dict): + log.error("Custom initialization options should be a dictionary, but got %s. Ignoring.", type(custom_init_options).__name__) + else: + log.info("Applying custom initialization options: %s", custom_init_options) + if init_options_key in self._params: + self._params[init_options_key].update(custom_init_options) + else: + self._params[init_options_key] = custom_init_options + log.info("Final initialization options: %s", self._params[init_options_key]) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index ddb238c5..189ed397 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -764,6 +764,13 @@ class SolidLanguageServer(ABC): """ self._abs_workspace_folders_all = self._abs_workspace_folders_indexed + self._abs_workspace_folders_additional + @property + def custom_settings(self) -> SolidLSPSettings.CustomLSSettings: + """ + The (user-provided) language server-specific settings. + """ + return self._custom_settings + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ Creates the dependency provider for this language server. From fd91dd10f67fdf5a644da7b7b6c2c5a5a1562963 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 3 Jul 2026 14:18:07 +0200 Subject: [PATCH 091/170] Clean up language server settings classes, improving docstrings --- src/solidlsp/ls.py | 5 ++--- src/solidlsp/ls_config.py | 5 ++++- src/solidlsp/settings.py | 13 ++++++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 189ed397..5ebdd6e0 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -638,9 +638,8 @@ class SolidLanguageServer(ABC): repository_root_path = os.path.abspath(repository_root_path) ls_class = config.code_language.get_ls_class() - # For now, we assume that all language server implementations have the same signature of the constructor - # (which, unfortunately, differs from the signature of the base class). - # If this assumption is ever violated, we need branching logic here. + # All language server implementations are required to use the same signature of the constructor + # (which differs from the signature of the base class constructor). ls = ls_class(config, repository_root_path, solidlsp_settings) ls.set_request_timeout(timeout) return ls diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index c666a2df..4ac6c844 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -834,10 +834,13 @@ class Language(str, Enum): @dataclass(frozen=True) class LanguageServerConfig: """ - Configuration parameters + Configuration parameters for a language server instance """ code_language: Language + """ + defines the language server to use + """ workspace_folders: list[str] = field(default_factory=lambda: ["."]) """ list of workspace folders to be used by the language server and to be fully indexed by SolidLSP. diff --git a/src/solidlsp/settings.py b/src/solidlsp/settings.py index 8d033d80..373f5d2f 100644 --- a/src/solidlsp/settings.py +++ b/src/solidlsp/settings.py @@ -18,6 +18,12 @@ log = logging.getLogger(__name__) @dataclass class SolidLSPSettings: + """ + Configures SolidLSP-specific data storage as well as global settings. + + Note: Server instance-specific settings belong in LanguageServerConfig, not here. + """ + solidlsp_dir: str = str(pathlib.Path.home() / ".solidlsp") """ Path to the directory in which to store global Solid-LSP data (which is not project-specific) @@ -34,13 +40,6 @@ class SolidLSPSettings: Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available. No documentation on options means no options are available. """ - additional_workspace_folders: list[str] = field(default_factory=list) - """ - Additional workspace folder paths for cross-package reference support. - Paths can be absolute or relative to the project root. Each folder is added as a workspace - folder in the LSP initialization, enabling language servers to discover symbols and references - across package boundaries (e.g. in monorepos). - """ def __post_init__(self) -> None: os.makedirs(str(self.solidlsp_dir), exist_ok=True) From cb7ba3f90513def8f8e0968fef44ac7334a2513e Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 3 Jul 2026 14:32:58 +0200 Subject: [PATCH 092/170] SolidLSP: Apply InitializeParamsBuilder to Angular/Svelte/Vue main servers The AngularLanguageServer, SvelteLanguageServer and VueLanguageServer classes (the primary ngserver/svelte-language-server/vue-language-server processes, all direct SolidLanguageServer subclasses) still hand-rolled their initialize params via _get_initialize_params, bypassing the builder. Only their companion *TypeScript* server classes (AngularTypeScriptServer, etc.) had been migrated previously. Convert the three main classes to _create_base_initialize_params so their processId/rootPath/rootUri/clientInfo/workspaceFolders are now supplied centrally by the InitializeParamsBuilder, consistent with every other language server. Server-specific initializationOptions (ngProbeLocations, svelte plugin configuration, Vue hybridMode) are preserved. Also update the Angular companion-cleanup test to monkeypatch the renamed _create_base_initialize_params (it patched the now-removed _get_initialize_params). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../angular_language_server.py | 18 ++++-------------- .../language_servers/svelte_language_server.py | 18 +++--------------- .../language_servers/vue_language_server.py | 18 ++++-------------- .../angular/test_angular_error_cases.py | 6 +++--- 4 files changed, 14 insertions(+), 46 deletions(-) diff --git a/src/solidlsp/language_servers/angular_language_server.py b/src/solidlsp/language_servers/angular_language_server.py index 5edd1dca..e7bf231d 100644 --- a/src/solidlsp/language_servers/angular_language_server.py +++ b/src/solidlsp/language_servers/angular_language_server.py @@ -70,7 +70,7 @@ from solidlsp.language_servers.typescript_language_server import ( from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -502,8 +502,7 @@ class AngularLanguageServer(SolidLanguageServer): else: log.debug("Found @angular/core at %s", found) - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: params: dict = { "locale": "en", "capabilities": { @@ -531,17 +530,8 @@ class AngularLanguageServer(SolidLanguageServer): "tsProbeLocations": [os.path.join(self._install_dir, "node_modules")], "forceStrictTemplates": False, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return params # type: ignore[return-value] + return params @override def _start_server(self) -> None: @@ -580,7 +570,7 @@ class AngularLanguageServer(SolidLanguageServer): try: log.info("Starting Angular language server (ngserver)") self.server.start() - init_params = self._get_initialize_params(self.repository_root_path) + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) log.debug("Angular LS initialize response: %s", init_response) self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index 8b65c822..3c7f377b 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -28,7 +28,6 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -440,7 +439,7 @@ class SvelteLanguageServer(SolidLanguageServer): self.server.notify.send_notification = send_notification_wrapped - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Svelte Language Server. @@ -451,8 +450,6 @@ class SvelteLanguageServer(SolidLanguageServer): The resulting dict is also stored as :attr:`_lsp_configuration` so ``workspace/configuration`` requests can be answered with real values. """ - root_uri = pathlib.Path(self.repo_path).as_uri() - # base configuration mirroring all plugin-sections from svelte-vscode initializationOptions lsp_config: dict[str, Any] = { "svelte": {}, @@ -514,17 +511,8 @@ class SvelteLanguageServer(SolidLanguageServer): "dontFilterIncompleteCompletions": True, "configuration": lsp_config, }, - "processId": os.getpid(), - "rootPath": self.repo_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(self.repo_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: def window_log_message(msg: dict) -> None: @@ -562,7 +550,7 @@ class SvelteLanguageServer(SolidLanguageServer): self._wrap_notify_send_for_ts_js_mirror() self.server.start() - init_params = self._get_initialize_params() + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) assert "documentSymbolProvider" in init_response["capabilities"], "Svelte LSP did not advertise documentSymbolProvider" diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index a29ab7f5..b4d79297 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -27,7 +27,7 @@ from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Location from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler import lsp_types -from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, SymbolInformation from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -617,8 +617,7 @@ class VueLanguageServer(SolidLanguageServer): return [vue_executable_path, "--stdio"], tsdk_path, ts_ls_executable_path - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params = { "locale": "en", "capabilities": { @@ -644,15 +643,6 @@ class VueLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { "vue": { "hybridMode": True, @@ -662,7 +652,7 @@ class VueLanguageServer(SolidLanguageServer): }, }, } - return initialize_params # type: ignore + return initialize_params def _start_typescript_server(self) -> None: try: @@ -811,7 +801,7 @@ class VueLanguageServer(SolidLanguageServer): log.info("Starting Vue server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/test/solidlsp/angular/test_angular_error_cases.py b/test/solidlsp/angular/test_angular_error_cases.py index f65e7d09..85e9375e 100644 --- a/test/solidlsp/angular/test_angular_error_cases.py +++ b/test/solidlsp/angular/test_angular_error_cases.py @@ -335,10 +335,10 @@ class TestAngularStartupCleanup: def boom(*_args: object, **_kwargs: object) -> dict: raise RuntimeError("simulated ngserver init failure") - # _get_initialize_params runs after both companions and ngserver have - # been spawned — exactly the failure window the cleanup wrapper + # _create_base_initialize_params runs after both companions and ngserver + # have been spawned — exactly the failure window the cleanup wrapper # protects against. - monkeypatch.setattr(ls, "_get_initialize_params", boom) + monkeypatch.setattr(ls, "_create_base_initialize_params", boom) try: with pytest.raises(RuntimeError, match="simulated ngserver init"): From 6d39822363caf70a42ce8f063c618eb31f0a7b80 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 3 Jul 2026 12:58:34 +0200 Subject: [PATCH 093/170] Make _create_base_initialize_params fully abstract, forcing subclasses to implement it --- src/solidlsp/ls.py | 2 +- test/solidlsp/test_rename_didopen.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 5ebdd6e0..73e1bfc3 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -3321,6 +3321,7 @@ class SolidLanguageServer(ABC): def _create_initialize_params_builder(self) -> InitializeParamsBuilder: return DefaultInitializeParamsBuilder(self) + @abstractmethod def _create_base_initialize_params(self) -> dict | InitializeParams: """ Subclasses should override this method to provide server-specific InitializeParams settings, @@ -3340,7 +3341,6 @@ class SolidLanguageServer(ABC): :return: the base InitializeParams settings """ - raise NotImplementedError def _create_initialize_params(self) -> InitializeParams: """ diff --git a/test/solidlsp/test_rename_didopen.py b/test/solidlsp/test_rename_didopen.py index 735e4642..f2902d89 100644 --- a/test/solidlsp/test_rename_didopen.py +++ b/test/solidlsp/test_rename_didopen.py @@ -7,6 +7,9 @@ class DummyLanguageServer(SolidLanguageServer): def _start_server(self) -> None: raise AssertionError("Not used in this test") + def _create_base_initialize_params(self) -> dict: + return {} + def test_request_rename_symbol_edit_opens_file_before_rename(tmp_path) -> None: (tmp_path / "index.ts").write_text("export const x = 1;\n", encoding="utf-8") From a7b638528bb5ba2a1d36309c65aab4b1e4023e36 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 3 Jul 2026 15:07:49 +0200 Subject: [PATCH 094/170] Cache FilenameMatcher instances, effectively turning them into per-language singletons --- src/solidlsp/ls_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 4ac6c844..e46106e2 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -8,6 +8,7 @@ import re from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum +from functools import cache from pathlib import Path from typing import TYPE_CHECKING, Self @@ -310,6 +311,7 @@ class Language(str, Enum): """ return self.get_ls_class().supports_implementation_request() + @cache def get_source_fn_matcher(self) -> FilenameMatcher: match self: case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY: From de370b4d008d9d17e101b6eefcf166a2d7d383cb Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:22:43 -0400 Subject: [PATCH 095/170] HLSL: build shader_language_server with cargo --locked to fix macOS CI Without --locked, cargo install freshly resolves shader-sense ^1.3.1, which since 2026-07-04 picks shader-sense 1.4.0. That release removed the public field GlslCompilationParams.preamble (split into preamble_path/preamble_content), which shader_language_server 1.3.1's server_config.rs still constructs, so the from-source build fails with E0560 and cargo exits 101. macOS is the only platform that builds the server from source (Linux/Windows download prebuilt release binaries), so only the macOS CI jobs fail: all 15 test/solidlsp/hlsl fixtures error at setup (run 28726267725, job 85184119276). --locked makes cargo honor the Cargo.lock packaged with the published crate, which pins shader-sense 1.3.1 - the dependency set the 1.3.1 release was built and tested from - and immunizes the build against future lockstep releases of the two crates. Verified locally: the unlocked build reproduces the exact E0560/exit-101 failure; the locked build compiles cleanly and yields a working binary reporting v1.3.1. Also adds --locked to the manual-install help text, matching the existing convention in taplo_server.py. --- CHANGELOG.md | 4 ++++ src/solidlsp/language_servers/hlsl_language_server.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c951222..2ebd342e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ Status of the `main` branch. Changes prior to the next official version change w * Language Servers: - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. + - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source + on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. + Without it, fresh dependency resolution pulled in shader-sense 1.4.0, which no longer compiles + against the pinned shader_language_server 1.3.1, breaking the macOS CI job. - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. Enables Yarn PnP setups with `typescript.tsdk` pointing at the Yarn-generated SDK. - TypeScript/VTS: disable automatic typing acquisition during initialization (no network diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py index 41b945ef..a41713eb 100644 --- a/src/solidlsp/language_servers/hlsl_language_server.py +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -81,7 +81,7 @@ class HlslLanguageServer(SolidLanguageServer): base_url = f"{_GITHUB_RELEASE_BASE}/{tag}" # macOS has no pre-built binaries; build from source via cargo install - cargo_install_cmd = ["cargo", "install", "shader_language_server", "--version", version, "--root", "."] + cargo_install_cmd = ["cargo", "install", "shader_language_server", "--version", version, "--locked", "--root", "."] deps = RuntimeDependencyCollection( [ @@ -141,7 +141,7 @@ class HlslLanguageServer(SolidLanguageServer): raise FileNotFoundError( "shader-language-server is not installed and no auto-install is available for your platform.\n" "Please install it using one of the following methods:\n" - " cargo: cargo install shader_language_server\n" + " cargo: cargo install shader_language_server --locked\n" " GitHub: Download from https://github.com/antaalt/shader-sense/releases\n" "On macOS, install the Rust toolchain (https://rustup.rs) and Serena will build from source automatically.\n" "See https://github.com/antaalt/shader-sense for more details." From 9ccd5f02a20da96d88fe63a240355311dc373b97 Mon Sep 17 00:00:00 2001 From: osmanrafiq <osmanrafiq@hotmail.com> Date: Mon, 6 Jul 2026 00:14:28 +0200 Subject: [PATCH 096/170] Fix Java Gradle import JAVA_HOME fallback (#1657) --- CHANGELOG.md | 2 + docs/02-usage/050_configuration.md | 4 +- .../language_servers/eclipse_jdtls.py | 60 +++++--- .../java/test_jdtls_path_resolution.py | 139 ++++++++++++++++++ 4 files changed, 185 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c951222..2d69a6db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ Status of the `main` branch. Changes prior to the next official version change w - `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed") when `julia` is enabled. #1577 - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 + - `Java`: use `JAVA_HOME` for Gradle import when `use_system_java_home` is enabled and `gradle_java_home` + is unset. #1657 - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 03f44761..29c6df7d 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -695,8 +695,8 @@ The following settings are supported for the Java language server: | `maven_user_settings` | `~/.m2/settings.xml` | Path to Maven `settings.xml` | | `gradle_user_home` | `~/.gradle` | Path to Gradle user home directory | | `gradle_wrapper_enabled` | `false` | Use the project's Gradle wrapper (`gradlew`) instead of the bundled Gradle distribution. Enable this for projects with custom plugins or repositories. | -| `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses the bundled JRE. | -| `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. | +| `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses `JAVA_HOME` if `use_system_java_home` is enabled and `JAVA_HOME` is set; otherwise it falls back to Serena's bundled JRE. | +| `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself and, when `gradle_java_home` is unset, Gradle import. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. | | `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. | | `vscode_java_version` | `1.54.0-923` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. | | `intellicode_version` | `1.2.30` | (vscode-java mode only) Override the IntelliCode VSIX version Serena downloads by default. | diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 320f7ed1..8c85998d 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -180,8 +180,10 @@ class EclipseJDTLS(SolidLanguageServer): - maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml) - gradle_user_home: Path to Gradle user home directory (default: ~/.gradle) - gradle_wrapper_enabled: Whether to use the project's Gradle wrapper (default: false) - - gradle_java_home: Path to JDK for Gradle (default: null, uses bundled JRE) - - use_system_java_home: Whether to use the system's JAVA_HOME for JDTLS itself (default: false) + - gradle_java_home: Path to JDK for Gradle (default: null, falls back to JAVA_HOME when + use_system_java_home is true, then the bundled JRE) + - use_system_java_home: Whether to use the system's JAVA_HOME for JDTLS itself and, when + gradle_java_home is unset, Gradle import (default: false) - jdtls_xmx: Maximum heap size for the JDTLS server JVM (default: "3G") - jdtls_xms: Initial heap size for the JDTLS server JVM (default: "100m") - intellicode_xmx: Maximum heap size for the IntelliCode embedded JVM (default: "1G") @@ -217,7 +219,7 @@ class EclipseJDTLS(SolidLanguageServer): # gradle_user_home: 'C:\\Users\\YourName\\.gradle' # Windows (use single quotes!) gradle_wrapper_enabled: true # set to true for projects with custom plugins/repositories gradle_java_home: "/path/to/jdk" # set to override Gradle's JDK - use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS + use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS and Gradle fallback jdtls_xmx: "3G" # maximum heap size for the JDTLS server JVM jdtls_xms: "100m" # initial heap size for the JDTLS server JVM intellicode_xmx: "1G" # maximum heap size for the IntelliCode embedded JVM @@ -904,6 +906,40 @@ class EclipseJDTLS(SolidLanguageServer): log.info(f"Using bundled JRE for JDTLS: {java_home}") return {"syntaxserver": "false", "JAVA_HOME": java_home} + def _resolve_gradle_java_home(self) -> str: + """ + Resolve the Java home configured for JDTLS Gradle import settings. + + :return: Java home selected from explicit Gradle override, system ``JAVA_HOME`` when enabled, or bundled runtime. + :raises FileNotFoundError: If an explicitly configured ``gradle_java_home`` path does not exist. + """ + # explicit Gradle override... + gradle_java_home = self._custom_settings.get("gradle_java_home") + if gradle_java_home is not None: + if not os.path.exists(gradle_java_home): + error_msg = ( + f"Gradle Java home not found: {gradle_java_home}. " + f"Fix: update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_java_home), " + f"or remove the setting to use JAVA_HOME or the bundled JRE fallback" + ) + log.error(error_msg) + raise FileNotFoundError(error_msg) + log.info(f"Using Gradle Java home from custom location: {gradle_java_home}") + return gradle_java_home + + # system JAVA_HOME fallback... + use_system_java_home = self._custom_settings.get("use_system_java_home", False) + if use_system_java_home: + system_java_home = os.environ.get("JAVA_HOME") + if system_java_home: + log.info(f"Using system JAVA_HOME for Gradle: {system_java_home}") + return system_java_home + log.warning("use_system_java_home is set but JAVA_HOME is not set in environment, falling back to bundled JRE for Gradle") + + # bundled runtime fallback... + log.info(f"Using bundled JRE for Gradle: {self.runtime_dependency_paths.jre_path}") + return self.runtime_dependency_paths.jre_path + def _create_base_initialize_params(self) -> dict: """ Returns the initialize parameters for the EclipseJDTLS server. @@ -977,20 +1013,8 @@ class EclipseJDTLS(SolidLanguageServer): f"Gradle wrapper {'enabled' if gradle_wrapper_enabled else 'disabled'} (configurable via ls_specific_settings -> java -> gradle_wrapper_enabled)" ) - # Gradle Java home: default to None, which means the bundled JRE is used - gradle_java_home = self._custom_settings.get("gradle_java_home") - if gradle_java_home is not None: - if not os.path.exists(gradle_java_home): - error_msg = ( - f"Gradle Java home not found: {gradle_java_home}. " - f"Fix: update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_java_home), " - f"or remove the setting to use the bundled JRE" - ) - log.error(error_msg) - raise FileNotFoundError(error_msg) - log.info(f"Using Gradle Java home from custom location: {gradle_java_home}") - else: - log.info(f"Using bundled JRE for Gradle: {self.runtime_dependency_paths.jre_path}") + # Gradle Java home: explicit setting, system JAVA_HOME when requested, then bundled runtime. + gradle_java_home = self._resolve_gradle_java_home() initialize_params = { "locale": "en", @@ -1312,7 +1336,7 @@ class EclipseJDTLS(SolidLanguageServer): # ./gradlew wrapper or a system-installed Gradle via its standard discovery rules. if self.runtime_dependency_paths.gradle_path is not None: gradle_settings["home"] = self.runtime_dependency_paths.gradle_path - gradle_settings["java"] = {"home": gradle_java_home if gradle_java_home is not None else self.runtime_dependency_paths.jre_path} + gradle_settings["java"] = {"home": gradle_java_home} return initialize_params def _start_server(self) -> None: diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 2154eb9b..3492134a 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -21,6 +21,7 @@ from solidlsp.language_servers.eclipse_jdtls import ( JDTLS_CONFIG_DIR_BY_PLATFORM, JDTLS_MIN_JDK_VERSION, EclipseJDTLS, + RuntimeDependencyPaths, ) from solidlsp.ls_exceptions import SolidLSPException from solidlsp.settings import SolidLSPSettings @@ -54,6 +55,144 @@ def _make_fake_jdtls_install( return root +def _make_runtime_dependency_paths(root: Path) -> RuntimeDependencyPaths: + """ + Build minimal runtime dependency paths for initialize-parameter tests. + + :param root: Root directory under which fake runtime files are created. + :return: Runtime paths sufficient for ``EclipseJDTLS._create_base_initialize_params``. + """ + jre_home = root / "jre-home" + jre_home.mkdir(parents=True) + jre_bin = jre_home / "bin" + jre_bin.mkdir() + jre_path = jre_bin / _JAVA_EXE_NAME + jre_path.touch() + + gradle_path = root / "gradle" + gradle_path.mkdir() + + launcher = root / "jdtls-launcher.jar" + launcher.touch() + config = root / "config" + config.mkdir() + lombok = root / "lombok.jar" + lombok.touch() + + return RuntimeDependencyPaths( + jre_path=str(jre_path), + jre_home_path=str(jre_home), + jdtls_launcher_jar_path=str(launcher), + jdtls_readonly_config_path=str(config), + lombok_jar_path=str(lombok), + gradle_path=str(gradle_path), + ) + + +def _make_uninitialized_jdtls( + repository_root: Path, custom_settings: dict[str, object], runtime_dependency_paths: RuntimeDependencyPaths +) -> EclipseJDTLS: + """ + Build an EclipseJDTLS instance without starting dependency setup or JDTLS. + + :param repository_root: Repository root reported to initialization-parameter generation. + :param custom_settings: Java language-server settings to attach to the instance. + :param runtime_dependency_paths: Runtime paths to attach to the instance. + :return: Partially initialized server suitable for pure parameter-generation tests. + """ + server = object.__new__(EclipseJDTLS) + server.repository_root_path = str(repository_root) + server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings) + server.runtime_dependency_paths = runtime_dependency_paths + return server + + +def _gradle_java_home(initialize_params: dict) -> str: + """ + Return the Gradle Java home from initialize parameters. + + :param initialize_params: JDTLS initialize-parameter payload. + :return: Configured Gradle Java home. + """ + return initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"]["java"]["home"] + + +# ---------------------------------------------------------------------------- +# Gradle Java-home initialize settings +# ---------------------------------------------------------------------------- + + +class TestGradleJavaHomeInitializeSettings: + def test_explicit_gradle_java_home_takes_precedence_over_java_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify explicit Gradle Java home wins over system ``JAVA_HOME``.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + explicit_gradle_jdk = tmp_path / "explicit-gradle-jdk" + explicit_gradle_jdk.mkdir() + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls( + repo, + {"gradle_java_home": str(explicit_gradle_jdk), "use_system_java_home": True}, + runtime_paths, + ) + + assert _gradle_java_home(server._create_base_initialize_params()) == str(explicit_gradle_jdk) + + def test_uses_java_home_for_gradle_when_system_java_home_enabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify Gradle import uses system ``JAVA_HOME`` when the system setting is enabled.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": True}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == str(env_jdk) + + def test_uses_bundled_runtime_for_gradle_when_system_java_home_disabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify Gradle import uses the bundled runtime when system Java home is disabled.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": False}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == runtime_paths.jre_path + + def test_uses_bundled_runtime_for_gradle_when_requested_java_home_is_unset( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify Gradle import falls back to the bundled runtime when ``JAVA_HOME`` is unset.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + monkeypatch.delenv("JAVA_HOME", raising=False) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": True}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == runtime_paths.jre_path + + def test_missing_explicit_gradle_java_home_raises(self, tmp_path: Path) -> None: + """Verify missing explicit Gradle Java home remains a configuration error.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + + server = _make_uninitialized_jdtls(repo, {"gradle_java_home": str(tmp_path / "missing-jdk")}, runtime_paths) + + with pytest.raises(FileNotFoundError, match="Gradle Java home not found"): + server._create_base_initialize_params() + + # ---------------------------------------------------------------------------- # _resolve_launcher_jar # ---------------------------------------------------------------------------- From e08dfc7133ae78a42fa251f7e98861bcf98060f0 Mon Sep 17 00:00:00 2001 From: AmirF194 <amirfathi.me@gmail.com> Date: Mon, 6 Jul 2026 15:32:51 -0600 Subject: [PATCH 097/170] Java (eclipse_jdtls): don't hard-ignore packages named dist/lib/classes/out (#1645) EclipseJDTLS.is_ignored_dirname hard-ignored directories named target/build/bin/out/classes/dist/lib. Several of these (dist, lib, classes, out) are valid Java package identifiers, so any source file whose package path contained one was dropped from directory traversal and became invisible to the symbol tools (find_symbol, get_symbols_overview, find_referencing_symbols) even when git check-ignore reported nothing. Per maintainer guidance in #1645, remove the override entirely: the base is_ignored_dirname already skips _ALWAYS_IGNORED_DIRS (VCS/venv/cache/IDE internals), and real build output is already excluded via .gitignore, so the hardcoded list was both redundant and harmful. Adds a regression test (no Java/JDTLS process required) that fails on main and asserts the formerly-hardcoded names are no longer ignored while _ALWAYS_IGNORED_DIRS still are. --- CHANGELOG.md | 4 +++ .../language_servers/eclipse_jdtls.py | 18 ------------ .../java/test_jdtls_path_resolution.py | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee6e2e8..8ac7d1a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ Status of the `main` branch. Changes prior to the next official version change w - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 - `Java`: use `JAVA_HOME` for Gradle import when `use_system_java_home` is enabled and `gradle_java_home` is unset. #1657 + - `Java`: stop hard-ignoring directories named `target`/`build`/`bin`/`out`/`classes`/`dist`/`lib` in + `EclipseJDTLS`. These are all valid Java package identifiers, so ignoring them by name hid legitimate + source from the symbol tools even when they were not gitignored. Removed the hardcoded override; real + build output is already excluded via `.gitignore`. #1645 - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 8c85998d..1539002b 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -254,24 +254,6 @@ class EclipseJDTLS(SolidLanguageServer): ls_resources_dir = self.ls_resources_dir(self._solidlsp_settings) return self.DependencyProvider(self._custom_settings, ls_resources_dir, self._solidlsp_settings, self.repository_root_path) - @override - def is_ignored_dirname(self, dirname: str) -> bool: - # Ignore common Java build directories from different build tools: - # - Maven: target - # - Gradle: build, .gradle - # - Eclipse: bin, .settings - # - IntelliJ IDEA: out, .idea - # - General: classes, dist, lib - return super().is_ignored_dirname(dirname) or dirname in [ - "target", # Maven - "build", # Gradle - "bin", # Eclipse - "out", # IntelliJ IDEA - "classes", # General - "dist", # General - "lib", # General - ] - class DependencyProvider(LanguageServerDependencyProvider): def __init__( self, diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 3492134a..7e7cd71c 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -615,3 +615,32 @@ class TestComputeWorkspaceHash: configured_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings) assert configured_hash != legacy_hash + + +# ---------------------------------------------------------------------------- +# is_ignored_dirname (directory-traversal filter) +# ---------------------------------------------------------------------------- + + +class TestIsIgnoredDirname: + """Regression for #1645: the Java language server must not hard-ignore directories whose names + collide with build-tool output (``lib``, ``dist``, ``classes``, ``out``, ...). Those are all + valid Java package identifiers, so ignoring them by name hid legitimate source from the symbol + tools even when ``git check-ignore`` reported nothing. Real build output is already filtered via + ``.gitignore``, so directory traversal should only skip the language-agnostic + ``_ALWAYS_IGNORED_DIRS`` (VCS/venv/cache/IDE internals) inherited from the base language server. + """ + + @pytest.fixture + def jdtls(self) -> EclipseJDTLS: + # is_ignored_dirname only reads the class-level _ALWAYS_IGNORED_DIRS, so an uninitialized + # instance is sufficient here (no Java or JDTLS process required). + return object.__new__(EclipseJDTLS) + + @pytest.mark.parametrize("dirname", ["lib", "dist", "classes", "out", "target", "build", "bin"]) + def test_valid_package_dirnames_are_not_ignored(self, jdtls: EclipseJDTLS, dirname: str) -> None: + assert jdtls.is_ignored_dirname(dirname) is False + + @pytest.mark.parametrize("dirname", [".git", ".venv", ".idea", ".serena", ".mypy_cache"]) + def test_always_ignored_dirs_are_still_ignored(self, jdtls: EclipseJDTLS, dirname: str) -> None: + assert jdtls.is_ignored_dirname(dirname) is True From 3ab9ff3847d09a353484093a40818f44f08d5ce1 Mon Sep 17 00:00:00 2001 From: AmirF194 <amirfathi.me@gmail.com> Date: Tue, 7 Jul 2026 11:50:56 -0600 Subject: [PATCH 098/170] fix(memory): reject absolute and escaping memory names in get_memory_file_path --- CHANGELOG.md | 2 ++ src/serena/memories/memory_manager.py | 51 +++++++++++++++++---------- test/serena/test_memories_manager.py | 46 ++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee6e2e8..8f97e067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,8 @@ Status of the `main` branch. Changes prior to the next official version change w * Memories: - Make memory iteration follow symbolic links + - Fix: a memory name that was absolute (e.g. `/etc/cron.d/backdoor`) or contained empty path + segments could write outside the `memories` folder. * Dependencies: - Add dependency `oslex` diff --git a/src/serena/memories/memory_manager.py b/src/serena/memories/memory_manager.py index 370e3572..daa03725 100644 --- a/src/serena/memories/memory_manager.py +++ b/src/serena/memories/memory_manager.py @@ -147,11 +147,41 @@ class MemoryManager: # use a callable replacement to avoid backreference interpretation of characters in ref_new return re.subn(pattern, lambda _m: ref_new, content) + def _resolve_memory_path(self, base_dir: Path, parts: Sequence[str]) -> Path: + """ + Builds the ``*.md`` path for ``parts`` under ``base_dir``, creating any parent + subdirectories, and guarantees the result stays inside ``base_dir``. + + The containment check is a defense-in-depth backstop for :meth:`get_memory_file_path`'s + up-front segment validation: even if a crafted name slipped through, the built path must + never escape the memories sandbox (which would let an agent read/write/delete arbitrary + files). The check runs *before* any directory is created, so a rejected name cannot leave + stray directories behind either. It is deliberately *lexical* (``normpath``, no symlink + resolution): directory symlinks placed inside the memories folder are a supported way to + share memories (e.g. a monorepo symlinking each submodule's memory dir), and those must + keep resolving to their targets at I/O time. + """ + filename = f"{parts[-1]}.md" + subdir = base_dir if len(parts) == 1 else base_dir.joinpath(*parts[:-1]) + candidate = subdir / filename + base_norm = Path(os.path.normpath(base_dir)) + if not Path(os.path.normpath(candidate)).is_relative_to(base_norm): + raise ValueError(f"Memory name resolves outside the memories directory. Got: {'/'.join(parts)}") + subdir.mkdir(parents=True, exist_ok=True) + return candidate + def get_memory_file_path(self, name: str) -> Path: name = self._sanitize_name(name) parts = name.split("/") + if ".." in parts: - raise ValueError(f"Memory name cannot contain '..' segments for security reasons. Got: {name}") + raise ValueError(f"Memory name cannot contain '..' segments. Got: {name}") + + # Reject absolute names and empty path segments: pathlib discards the base directory when + # joined with an absolute path (e.g. "/etc/cron.d/backdoor" would reset to "/etc/cron.d"), + # letting a memory name escape the sandbox. A leading "/" produces an empty first segment. + if os.path.isabs(name) or "" in parts: + raise ValueError(f"Memory name cannot be absolute or contain empty path segments. Got: {name}") if self._is_global(name): if name == self.GLOBAL_TOPIC: @@ -160,26 +190,11 @@ class MemoryManager: ) # Strip "global/" prefix and resolve against global dir sub_name = name[len(self.GLOBAL_TOPIC) + 1 :] - parts = sub_name.split("/") - filename = f"{parts[-1]}.md" - if len(parts) > 1: - subdir = self._global_memory_dir / "/".join(parts[:-1]) - subdir.mkdir(parents=True, exist_ok=True) - return subdir / filename - return self._global_memory_dir / filename + return self._resolve_memory_path(self._global_memory_dir, sub_name.split("/")) # Project-local memory assert self._project_memory_dir is not None, "Project dir was not passed at initialization" - - filename = f"{parts[-1]}.md" - - if len(parts) > 1: - # Create subdirectory path - subdir = self._project_memory_dir / "/".join(parts[:-1]) - subdir.mkdir(parents=True, exist_ok=True) - return subdir / filename - - return self._project_memory_dir / filename + return self._resolve_memory_path(self._project_memory_dir, parts) def _check_write_access(self, name: str, is_tool_context: bool) -> None: # in tool context, memories can be read-only diff --git a/test/serena/test_memories_manager.py b/test/serena/test_memories_manager.py index 79be6832..158ab379 100644 --- a/test/serena/test_memories_manager.py +++ b/test/serena/test_memories_manager.py @@ -303,6 +303,52 @@ class TestListMemoriesFollowsSymlinks: assert manager.load_memory("submodule/nested/sub_b") == "# b" +class TestGetMemoryFilePathContainment: + """Regression: :meth:`MemoryManager.get_memory_file_path` must never return a path outside the + memories sandbox. An *absolute* memory name (e.g. ``/etc/cron.d/backdoor``) previously caused + ``pathlib`` to discard the base directory and reset to the absolute path, escaping + ``.serena/memories``. Since save/load/delete/move all route through this method, that let an + agent read, write, or delete arbitrary ``*.md`` files outside the sandbox. The guard must also + hold for the ``global/...`` branch, and the existing ``..`` rejection must keep working. + """ + + def test_absolute_project_memory_name_escapes_sandbox_is_rejected(self, fs_manager: MemoryManager, tmp_path) -> None: + # An absolute name pointing outside the memories dir. On the buggy code this silently + # returned a path under ``escape_target`` (outside ``memories``); the fix rejects it. + escape = str(tmp_path / "escape_target" / "backdoor") + with pytest.raises(ValueError): + fs_manager.get_memory_file_path(escape) + + def test_absolute_project_memory_name_system_path_is_rejected(self, fs_manager: MemoryManager) -> None: + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("/etc/cron.d/backdoor") + + def test_absolute_global_memory_name_is_rejected(self, fs_manager: MemoryManager, tmp_path, monkeypatch) -> None: + # "global//etc/..." routes through the global branch; the empty segment / absolute sub-name + # must be rejected there too. Redirect the global dir so the test never touches the real one. + monkeypatch.setattr(fs_manager, "_global_memory_dir", tmp_path / "global") + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("global//etc/cron.d/backdoor") + + def test_dotdot_segment_still_rejected(self, fs_manager: MemoryManager) -> None: + # the pre-existing guard must keep working + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("../../etc/passwd") + + @pytest.mark.parametrize("name", ["notes", "topic/notes", "a/b/c/deep", "mem:topic/foo.md"]) + def test_accepted_project_names_resolve_inside_memories_dir(self, fs_manager: MemoryManager, name: str) -> None: + assert fs_manager._project_memory_dir is not None + path = fs_manager.get_memory_file_path(name) + assert path.resolve().is_relative_to(fs_manager._project_memory_dir.resolve()) + + def test_accepted_global_name_resolves_inside_global_dir(self, fs_manager: MemoryManager, tmp_path, monkeypatch) -> None: + global_dir = tmp_path / "global" + global_dir.mkdir() + monkeypatch.setattr(fs_manager, "_global_memory_dir", global_dir) + path = fs_manager.get_memory_file_path("global/topic/notes") + assert path.resolve().is_relative_to(global_dir.resolve()) + + class TestValidateReferentialIntegrity: def test_clean_report_when_all_references_resolve(self, fs_manager: MemoryManager) -> None: _write(fs_manager, "auth", "# auth notes") From d32fb248d443c4f0ccac091eddb257eead672d36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:40:46 +0000 Subject: [PATCH 099/170] Bump the uv group across 1 directory with 2 updates Bumps the uv group with 2 updates in the / directory: [agno](https://github.com/agno-agi/agno) and [soupsieve](https://github.com/facelessuser/soupsieve). Updates `agno` from 2.5.10 to 2.6.6 - [Release notes](https://github.com/agno-agi/agno/releases) - [Commits](https://github.com/agno-agi/agno/compare/v2.5.10...v2.6.6) Updates `soupsieve` from 2.8.3 to 2.8.4 - [Release notes](https://github.com/facelessuser/soupsieve/releases) - [Commits](https://github.com/facelessuser/soupsieve/compare/2.8.3...2.8.4) --- updated-dependencies: - dependency-name: agno dependency-version: 2.6.6 dependency-type: direct:production dependency-group: uv - dependency-name: soupsieve dependency-version: 2.8.4 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] <support@github.com> --- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bdd04a6f..2daa2dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ dev = [ "pyasn1==0.6.3", "PyJWT==2.12.0", ] -agno = ["agno==2.5.10", "sqlalchemy==2.0.41"] # agno bumped for session state overwrite CVE +agno = ["agno==2.6.6", "sqlalchemy==2.0.41"] # agno bumped for session state overwrite CVE google = ["google-genai==1.27.0"] [project.urls] diff --git a/uv.lock b/uv.lock index 3595e84a..d82e273a 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agno" -version = "2.5.10" +version = "2.6.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -38,9 +38,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/ac/006e5907991b7c8a99d3ed7b39620a9fa4d3b5d069beba478202fe02d97f/agno-2.5.10.tar.gz", hash = "sha256:a00b2e700ac5661fc0af42996141524f75e2a374b7d7abc411b2bdfe3a9346d7", size = 1797557, upload-time = "2026-03-17T17:15:05.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/b7/17c737b7a482b204eeb287e2c1616ed4abea93d71501810c37f269d002fb/agno-2.6.6.tar.gz", hash = "sha256:c8767ff7528a4f8b7aa8c0543592c3b9cc3c2e3f5a2852555a1c493408df3acf", size = 2025094, upload-time = "2026-05-14T07:35:01.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/8b/df66cab1b74ea915f847e081abf21a498323580b34312cca6226895aff7f/agno-2.5.10-py3-none-any.whl", hash = "sha256:17ccea453a2007708499265f1df3696ee53ca9a286ca42551d2c5d20d9a41715", size = 2141266, upload-time = "2026-03-17T17:15:03.44Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d1/1a9078840c00cd2b6a2968dd11cc107c72290424ff7dbd4baf9a87617ac3/agno-2.6.6-py3-none-any.whl", hash = "sha256:ba8795e020d2a036af312734c1a796b5064466d4c45ab6d64ea51ec5ec2b2cd7", size = 2405077, upload-time = "2026-05-14T07:34:59.063Z" }, ] [[package]] @@ -2875,7 +2875,7 @@ google = [ [package.metadata] requires-dist = [ - { name = "agno", marker = "extra == 'agno'", specifier = "==2.5.10" }, + { name = "agno", marker = "extra == 'agno'", specifier = "==2.6.6" }, { name = "anthropic", specifier = "==0.59.0" }, { name = "beautifulsoup4", specifier = "==4.14.2" }, { name = "cryptography", specifier = "==48.0.1" }, @@ -2991,11 +2991,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] From 90334cc131b314a9b2fa7faf4a544c59237a9f0a Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:24:32 -0400 Subject: [PATCH 100/170] Solidity: await validation completion for diagnostics instead of a fixed wait The Nomic solidity-language-server validates asynchronously: document changes are debounced (~250ms), validation runs a forge/solc compile, and completion is signalled with the custom notification custom/validation-job-status -- emitted on every completion path (success, validation failure, job errors; verified in the bundled 0.8.4 package, which publishes the document's diagnostics before the signal). Serena ignored the signal ("Unhandled method 'custom/validation-job-status'") and waited a fixed 2.5s for a non-empty publishDiagnostics, returning [] whenever validation finished later. Warm environments fit the window; slow or cold ones (macOS and Windows CI runners, which download solc 0.8.20 on every run -- the CI cache only covers ~/.serena/language_servers/static, not ~/.local/share/svm) often do not. This is the root cause of the empty-diagnostics failures of test_file_diagnostics[solidity]: xfailed on macOS since its introduction (bd7e64e8) and failing on Windows on Tests run #4236, job catch-all (windows-latest). Shrinking the wait reproduces the exact failure signature (AssertionError: []) on Linux. Changes: - Handle custom/validation-job-status (threading.Event + last payload). The payload carries no document URI, so the event is global to the server instance. - request_text_document_diagnostics keeps the 2.5s non-empty publish wait as the fast path. When it misses, it awaits the completion signal against a 60s deadline (aligned with the indexing wait) and takes the generation-advancing publication for the request URI, accepting an empty result as the validated answer; a completion that brings no publication for the URI (e.g. one belonging to another document) re-arms the wait until the deadline. If the signal never arrives, a warning is logged and the previous empty-result behavior applies, bounded by the deadline. - Remove the darwin xfail from test_file_diagnostics; add regression tests for the fallback delivery (fast path forced to miss), for the never-signalled bound, and for re-arming on a spurious completion belonging to another document. - CHANGELOG entry under Language Servers. Design notes: - Mirrors #1639 (TypeScript/VTS: event-based indexing wait replacing a fixed sleep). - The simpler alternative -- accepting empty publications and raising the fixed wait -- was rejected: without the completion signal, an early clear-on-open publication would be taken as the answer, and every clean file would stall for the full wait. - Supersedes the quarantine approach of draft PR #1674 (broadening the xfail to non-Linux); whichever lands second resolves the trivial conflict on the xfail block. --- CHANGELOG.md | 1 + .../solidity_language_server.py | 66 ++++++++++- .../solidity/test_solidity_diagnostics.py | 105 ++++++++++++++++-- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac7d1a1..1275013b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Make tool call errors surface explicitly as errors at the MCP protocol level * Language Servers: + - Solidity: fix diagnostics intermittently coming back empty on slow or cold environments (macOS/Windows CI). - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 515bd5ce..434c536a 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -10,7 +10,7 @@ import pathlib import platform import shutil import threading -from time import sleep +from time import monotonic, sleep from typing import Any from overrides import override @@ -40,6 +40,14 @@ class SolidityLanguageServer(SolidLanguageServer): Requires Node.js and npm to be installed. """ + _VALIDATION_COMPLETION_TIMEOUT = 60.0 + """upper bound for awaiting the validation-completion signal (``custom/validation-job-status``), + aligned with the indexing wait; on cold environments validation includes downloading the pinned + solc version""" + + _POST_VALIDATION_PUBLISH_WAIT = 2.5 + """grace period for the diagnostics publication that accompanies the completion signal""" + @staticmethod def _determine_log_level(line: str) -> int: """Suppress known non-critical stderr output from the Solidity language server.""" @@ -66,6 +74,11 @@ class SolidityLanguageServer(SolidLanguageServer): "solidity", solidlsp_settings, ) + self._validation_completed = threading.Event() + """set when the language server reports an asynchronous validation run as finished + (``custom/validation-job-status``); cleared at the start of each diagnostics request""" + self._last_validation_status: Any | None = None + """payload of the most recent ``custom/validation-job-status`` notification, kept for logging""" def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @@ -242,11 +255,19 @@ class SolidityLanguageServer(SolidLanguageServer): if indexed_count[0] >= expected_count: all_indexed.set() + def on_validation_job_status(params: Any) -> None: + # signals completion of an asynchronous validation run (a forge/solc compile); + # the payload carries no document URI, so the event is global to the server instance + self._last_validation_status = params + log.debug(f"Solidity LSP: validation job finished: {params}") + self._validation_completed.set() + 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_notification("custom/file-indexed", on_file_indexed) + self.server.on_notification("custom/validation-job-status", on_validation_job_status) log.info("Starting Solidity language server process") self.server.start() @@ -300,6 +321,9 @@ class SolidityLanguageServer(SolidLanguageServer): "character": len(lines[-1]), } + # any validation that finishes after this point may satisfy the fallback wait below + self._validation_completed.clear() + self.server.notify.did_open_text_document( { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict LSPConstants.TEXT_DOCUMENT: { @@ -328,12 +352,52 @@ class SolidityLanguageServer(SolidLanguageServer): ], } ) + # fast path: wait for a non-empty diagnostics publication (warm environments) diagnostics = self._wait_for_relevant_published_diagnostics( uri=uri, after_generation=diagnostics_before_request, timeout=self._get_published_diagnostics_wait_timeout(True), allow_cached=True, ) + + # fallback: validation (a forge/solc compile) runs asynchronously in the server and can + # exceed the initial wait on slow or cold environments (e.g. CI runners downloading the + # pinned solc); await its completion signal, then take the publication made for this + # request, accepting an empty result as the validated answer. The signal carries no + # document URI, so a completion belonging to another document can wake the wait early; + # re-arm and keep waiting until the deadline in that case. + if diagnostics is None: + # fast path missed; validation is still running -- await its completion signal so the + # wait below is visible in logs on slow/cold environments + log.debug( + f"Solidity diagnostics not yet published for {uri}; " + f"awaiting validation completion (up to {self._VALIDATION_COMPLETION_TIMEOUT:.0f}s)" + ) + deadline = monotonic() + self._VALIDATION_COMPLETION_TIMEOUT + while diagnostics is None: + remaining = deadline - monotonic() + if remaining <= 0 or not self._validation_completed.wait(timeout=remaining): + log.warning( + f"Solidity validation completion was not signalled within " + f"{self._VALIDATION_COMPLETION_TIMEOUT:.0f}s for {uri} " + f"(last status: {self._last_validation_status}); returning without diagnostics" + ) + break + diagnostics = self._wait_for_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._POST_VALIDATION_PUBLISH_WAIT, + ) + if diagnostics is None: + # a completion fired but published nothing for our URI (e.g. a run for another + # document); re-arm and keep waiting so the ongoing wait stays visible + log.debug(f"Solidity validation completed without diagnostics for {uri} yet; continuing to wait") + self._validation_completed.clear() + else: + log.debug( + f"Solidity diagnostics resolved via validation completion " + f"(status={self._last_validation_status}): {len(diagnostics)} diagnostic(s)" + ) finally: self.server.notify.did_close_text_document( { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict diff --git a/test/solidlsp/solidity/test_solidity_diagnostics.py b/test/solidlsp/solidity/test_solidity_diagnostics.py index fd3cd6ba..21a11f3c 100644 --- a/test/solidlsp/solidity/test_solidity_diagnostics.py +++ b/test/solidlsp/solidity/test_solidity_diagnostics.py @@ -1,19 +1,42 @@ -import sys - import pytest from solidlsp import SolidLanguageServer +from solidlsp.language_servers.solidity_language_server import SolidityLanguageServer from solidlsp.ls_config import Language from test.solidlsp.util.diagnostics import assert_file_diagnostics +class _NeverSetEvent: + """Stand-in for :class:`threading.Event` whose wait never succeeds.""" + + def wait(self, timeout: float | None = None) -> bool: + return False + + def clear(self) -> None: + return None + + def set(self) -> None: + return None + + +class _AlwaysSignalledEvent: + """Stand-in for :class:`threading.Event` whose wait always reports a completion; counts clears.""" + + def __init__(self) -> None: + self.cleared = 0 + + def wait(self, timeout: float | None = None) -> bool: + return True + + def clear(self) -> None: + self.cleared += 1 + + def set(self) -> None: + return None + + @pytest.mark.solidity class TestSolidityDiagnostics: - @pytest.mark.xfail( - sys.platform == "darwin", - reason="Unknown — passes on Ubuntu but consistently fails on macOS CI; root cause not yet identified.", - strict=False, - ) @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: assert_file_diagnostics( @@ -22,3 +45,71 @@ class TestSolidityDiagnostics: (), min_count=1, ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_via_validation_completion( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Diagnostics must still arrive via the validation-completion fallback when the initial + publish wait does not observe them (the failure mode of cold/slow CI runners). + """ + assert isinstance(language_server, SolidityLanguageServer) + + # force the fast path to miss so that only the validation-completion fallback can deliver + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + + assert_file_diagnostics( + language_server, + "contracts/DiagnosticsSample.sol", + (), + min_count=1, + ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_without_validation_signal( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A server that never signals validation completion must yield an empty result within the + bound instead of hanging or raising. + """ + assert isinstance(language_server, SolidityLanguageServer) + + # force the fast path to miss and make the completion signal unobtainable + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + monkeypatch.setattr(language_server, "_VALIDATION_COMPLETION_TIMEOUT", 0.2) + monkeypatch.setattr(language_server, "_validation_completed", _NeverSetEvent()) + + diagnostics = language_server.request_text_document_diagnostics("contracts/DiagnosticsSample.sol", min_severity=1) + assert diagnostics == [] + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_rearms_after_spurious_completion( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A completion signal belonging to another document (the payload carries no URI) must + re-arm the fallback wait instead of ending the request without diagnostics. + """ + assert isinstance(language_server, SolidityLanguageServer) + + expected_diagnostic = { + "uri": "file:///spurious-completion-test", + "severity": 1, + "message": "delivered after re-arm", + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "code": None, + } + # first wake is spurious (no publication for the request URI), second delivers + grace_reads: list[list | None] = [None, [expected_diagnostic]] + event = _AlwaysSignalledEvent() + + # force the fast path to miss and script the fallback's wake/read sequence + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + monkeypatch.setattr(language_server, "_validation_completed", event) + monkeypatch.setattr(language_server, "_wait_for_published_diagnostics", lambda **kwargs: grace_reads.pop(0)) + + diagnostics = language_server.request_text_document_diagnostics("contracts/DiagnosticsSample.sol", min_severity=1) + + # one clear at request start plus exactly one re-arm after the spurious wake + assert event.cleared == 2 + assert not grace_reads # both scripted reads consumed + assert [diagnostic["message"] for diagnostic in diagnostics] == ["delivered after re-arm"] From 9580a6aec75a012faa2e1d91ebb8c627dca1190a Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:43:57 -0400 Subject: [PATCH 101/170] Svelte + TypeScript: fail loudly on companion TS timeouts; deterministic test fixture Make the Svelte companion TypeScript server raise on readiness and indexing timeouts instead of silently proceeding with a partial index, turning flaky/wrong cross-file results into clear failures: - TypeScriptLanguageServer: timeout handling factored into overridable hooks (_handle_server_ready_timeout, _handle_project_indexing_timeout); the base server keeps its historical permissive behavior, so plain TypeScript setups are unchanged. - SvelteTypeScriptServer: both overrides raise TimeoutError; only the indexing-timeout error includes describe_indexing_state(), instead of serving requests from a cold or partially indexed program. - Configurable server_ready_timeout / indexing_timeout via ls_specific_settings. - SvelteLanguageServer: file-open failures during companion preparation raise SvelteCompanionPreparationError with a count-first file listing (capped at 10), chained to the first underlying error so the cause is visible without DEBUG logging. Apply the same fail-loudly principle to the test fixture bootstrap: - Svelte test repo: npm ci with a committed package-lock.json, removing dependency-resolution non-determinism from CI runs. - Run svelte-kit sync explicitly during fixture setup and fail loudly if .svelte-kit/tsconfig.json cannot be generated. The fixture's "prepare" script masks sync failures ("svelte-kit sync || echo ''"), and a known npm bug (npm/cli#4828) can silently skip platform-specific optional dependencies; the result is unresolvable $lib path aliases and cross-file tests failing with partial results instead of a clear, actionable error. - codespell: skip third-party fixture repositories, using the anchored pattern ./test/resources/repos (codespell matches skip globs against the ./-prefixed paths it walks, so an anchor-less pattern never matches). Also add direct regression tests for the timeout-policy split (test/solidlsp/test_typescript_timeout_policy.py: base stays permissive, companion raises, settings plumbing and precedence, and start-or-completion indexing-wait semantics). The tests use no mocking or patching of any kind; a hand-written call-recording fake drives the companion guard against real file discovery under a pytest tmp_path. They spawn no LS process and avoid external readiness races; one wait test uses a real Timer with a 30s ceiling. The configuration docs state that the TypeScript table lists both knobs; the Svelte table lists indexing_timeout, with readiness inheritance documented in prose. --- CHANGELOG.md | 5 + docs/02-usage/050_configuration.md | 8 + pyproject.toml | 4 +- .../svelte_language_server.py | 61 +- .../typescript_language_server.py | 113 +- .../repos/svelte/test_repo/.gitignore | 3 +- .../repos/svelte/test_repo/package-lock.json | 1864 +++++++++++++++++ test/solidlsp/svelte/conftest.py | 78 +- test/solidlsp/svelte/test_svelte_basic.py | 5 +- .../solidlsp/svelte/test_svelte_references.py | 8 +- .../test_typescript_timeout_policy.py | 269 +++ 11 files changed, 2358 insertions(+), 60 deletions(-) create mode 100644 test/resources/repos/svelte/test_repo/package-lock.json create mode 100644 test/solidlsp/test_typescript_timeout_policy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac7d1a1..6c07723a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,11 @@ Status of the `main` branch. Changes prior to the next official version change w processed by the Svelte LS instead of the TypeScript LS. - `SvelteLanguageServer`: Fix document-symbol requests for TypeScript/JavaScript files returning empty results in svelte-only mode (`languages: [svelte]`. #1552 + - Svelte + TypeScript: make companion TypeScript server raise on readiness and indexing timeouts (instead + of silent "proceeding anyway"), so that partial indexing surfaces as a clear failure instead of flaky/wrong + cross-file results; add configurable timeouts (`server_ready_timeout`, `indexing_timeout`) + and overridable timeout hooks (base TS stays permissive). Svelte test fixture now uses `npm ci` + + committed `package-lock.json`. - `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed") when `julia` is enabled. #1577 - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 29c6df7d..ec7e5f95 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1031,6 +1031,7 @@ Supported settings: | `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. | | `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | | `indexing_timeout` | `30.0` | Timeout in seconds for waiting on tsserver's `$/progress` project-indexing signal (both at startup and before the first cross-file reference query). If indexing does not complete within this window, Serena logs a warning and proceeds anyway. Increase it for very large projects. | +| `server_ready_timeout` | `10.0` | Timeout in seconds for waiting on the server-ready signal after initialization. If the signal does not arrive within this window, Serena logs a message and proceeds anyway. | #### Svelte @@ -1048,8 +1049,15 @@ Supported settings: | `typescript_language_server_version` | `5.1.3` (falls back to `ls_specific_settings.typescript.typescript_language_server_version`) | Override the `typescript-language-server` npm package version for the companion server. | | `typescript_svelte_plugin_version` | `0.3.52` | Override the `typescript-svelte-plugin` npm package version used for `.svelte`-aware TS resolution. | | `npm_registry` | `null` | Override the npm registry Serena uses for all managed installs. | +| `indexing_timeout` | `120.0` (falls back to `ls_specific_settings.typescript.indexing_timeout`) | Timeout in seconds for the companion TS server to finish indexing `.svelte` files. On timeout, startup fails with a diagnostic indexing-state summary instead of serving cross-file results from a partially indexed program. | | `initialization_options_configuration` | `{}` | Deep-merge overrides for any of the ten plugin configuration sections (`svelte`, `prettier`, `emmet`, `typescript`, `javascript`, `js/ts`, `css`, `less`, `scss`, `html`). | +Unlike the plain TypeScript server, the companion is strict about readiness: it raises on server-ready and +indexing timeouts instead of proceeding with a cold or partially indexed program (which would silently degrade +cross-file renames and references). The companion reads `server_ready_timeout` and `indexing_timeout` from +`ls_specific_settings.typescript` with raised defaults (30s and 120s respectively); `ls_specific_settings.svelte.indexing_timeout` +takes precedence for the `.svelte`-file indexing wait. + All four packages are tracked via a version file; changing any version setting triggers a clean reinstall. #### TypeScript via `vtsls` diff --git a/pyproject.toml b/pyproject.toml index bdd04a6f..057d1e3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -382,7 +382,9 @@ markers = [ [tool.codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file -skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py' +# We skip third-party test fixture repositories entirely (they contain +# external package lockfiles, minified code, and arbitrary dependency strings). +skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py,./test/resources/repos' check-hidden = true ignore-regex = '\.\w+' ignore-words-list = 'paket,EDN,als,ue' diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index 3c7f377b..ad82617e 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -34,6 +34,12 @@ log = logging.getLogger(__name__) TS_EXT = frozenset({".ts", ".tsx", ".mts", ".cts"}) JS_EXT = frozenset({".js", ".jsx", ".mjs", ".cjs"}) SVELTE_EXT = frozenset({".svelte"}) +# cap the file listing in SvelteCompanionPreparationError so a large repo cannot bloat the message +_MAX_FAILED_FILES_IN_ERROR = 10 + + +class SvelteCompanionPreparationError(RuntimeError): + """Raised when the companion TypeScript server cannot be prepared deterministically.""" def _is_ts_file(uri: str) -> bool: @@ -54,6 +60,9 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer): Spawned and owned by :class:`SvelteLanguageServer`; not instantiated directly. """ + INDEXING_PROGRESS_TIMEOUT = 120.0 + SERVER_READY_TIMEOUT = 30.0 + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): """Returns the pre-installed typescript-language-server binary. @@ -142,6 +151,16 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer): self.server.on_request("workspace/configuration", workspace_configuration_handler) super()._start_server() + @override + def _handle_server_ready_timeout(self, timeout: float) -> None: + raise TimeoutError(f"Svelte companion TypeScript server did not become ready within {timeout:.0f}s") + + @override + def _handle_project_indexing_timeout(self, timeout: float) -> None: + raise TimeoutError( + f"Svelte companion TypeScript server project indexing did not complete within {timeout:.0f}s ({self.describe_indexing_state()})" + ) + class SvelteLanguageServer(SolidLanguageServer): """ @@ -151,6 +170,9 @@ class SvelteLanguageServer(SolidLanguageServer): * ``svelte_language_server_version``: version of ``svelte-language-server`` to install (default: ``0.18.0``). * ``npm_registry``: optional alternative npm-compatible registry URL. + * ``indexing_timeout``: optional timeout in seconds for companion TS indexing of + Svelte files. Falls back to ``ls_specific_settings["typescript"].indexing_timeout`` + or the Svelte companion default. * ``initialization_options_configuration``: optional dict merged into ``initializeParams.initializationOptions.configuration`` (same top-level keys as in Svelte Language Tools: ``svelte``, ``prettier``, ``typescript``, …). @@ -313,6 +335,15 @@ class SvelteLanguageServer(SolidLanguageServer): log.debug("Error processing svelte file %s: %s", svelte_file, exc) return svelte_files + def _get_companion_indexing_timeout(self) -> float: + """:return: maximum seconds to wait for companion TS indexing after opening Svelte files.""" + ts_settings = self._solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + timeout = self._custom_settings.get( + "indexing_timeout", + ts_settings.get("indexing_timeout", SvelteTypeScriptServer.INDEXING_PROGRESS_TIMEOUT), + ) + return float(timeout) + def _ensure_svelte_files_indexed_on_ts_server(self) -> None: """Open all .svelte files on the companion TS server so the plugin includes them in the TS program. @@ -332,6 +363,8 @@ class SvelteLanguageServer(SolidLanguageServer): # prepare progress tracking BEFORE opening files to avoid a race self._ts_server.expect_indexing() + failed_svelte_files = [] + first_open_error: Exception | None = None for svelte_file in svelte_files: try: with self._ts_server.open_file(svelte_file) as file_buffer: @@ -339,15 +372,29 @@ class SvelteLanguageServer(SolidLanguageServer): self._indexed_svelte_file_uris.append(file_buffer.uri) except Exception as exc: log.debug("Failed to open %s on companion TS server: %s", svelte_file, exc) + if first_open_error is None: + first_open_error = exc + failed_svelte_files.append(svelte_file) + + if failed_svelte_files: + shown_files = sorted(failed_svelte_files)[:_MAX_FAILED_FILES_IN_ERROR] + remainder = len(failed_svelte_files) - len(shown_files) + listing = ", ".join(shown_files) + (f" and {remainder} more" if remainder else "") + raise SvelteCompanionPreparationError( + f"Failed to open {len(failed_svelte_files)} Svelte file(s) on companion TypeScript server: {listing}" + ) from first_open_error self._svelte_files_indexed = True log.info("Svelte file indexing complete; waiting for companion TS server to finish processing") - timeout = TypeScriptLanguageServer.INDEXING_PROGRESS_TIMEOUT - if self._ts_server.wait_for_indexing(timeout=timeout): + timeout = self._get_companion_indexing_timeout() + if self._ts_server._wait_for_indexing_start_or_completion(timeout=timeout): log.info("Companion TypeScript server finished indexing .svelte files") else: - log.warning("Timeout (%ss) waiting for companion TS server to index .svelte files; proceeding anyway", timeout) + raise TimeoutError( + f"Companion TypeScript server did not finish indexing {len(svelte_files)} .svelte files within {timeout:.0f}s " + f"({self._ts_server.describe_indexing_state()})" + ) def _cleanup_indexed_svelte_files(self) -> None: """Decrement ref-counts for all .svelte files opened during indexing.""" @@ -384,13 +431,13 @@ class SvelteLanguageServer(SolidLanguageServer): ) log.info("Starting companion SvelteTypeScriptServer") self._ts_server.start() - log.info("Waiting for companion SvelteTypeScriptServer to be ready ...") - if not self._ts_server.server_ready.wait(timeout=30.0): - log.warning("Timeout waiting for companion SvelteTypeScriptServer; proceeding anyway") - self._ts_server.server_ready.set() self._ts_server_started = True log.info("Companion SvelteTypeScriptServer ready") self._ensure_svelte_files_indexed_on_ts_server() + except (TimeoutError, SvelteCompanionPreparationError): + log.exception("Failed to prepare companion SvelteTypeScriptServer; aborting Svelte server startup") + self._stop_typescript_server() + raise except Exception: log.exception("Error starting companion SvelteTypeScriptServer; TS-side operations degrade to svelte LS") self._ts_server = None diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 278cd10c..03bda1c3 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -73,6 +73,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): - typescript_version: Version of TypeScript to install (default: "5.9.3") - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") - indexing_timeout: float, timeout in seconds for project indexing (default: 30.0) + - server_ready_timeout: float, timeout in seconds for the server-ready signal (default: 10.0) """ @classmethod @@ -82,6 +83,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): # Safety timeout for $/progress-based indexing wait. Normally the event fires # well within this window; the timeout is only hit if the server never sends progress. INDEXING_PROGRESS_TIMEOUT = 30.0 + SERVER_READY_TIMEOUT = 10.0 def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ @@ -114,6 +116,35 @@ class TypeScriptLanguageServer(SolidLanguageServer): """ return self._indexing_complete.wait(timeout=timeout) + def _wait_for_indexing_start_or_completion(self, timeout: float, start_grace: float | None = None) -> bool: + """Wait until TypeScript indexing has started and drained, or provably never started. + + :param timeout: Maximum seconds to wait once active indexing progress is observed. + :param start_grace: Maximum seconds to wait for progress to begin after opening files. + :return: True if indexing completed or no progress began within the grace period, False on timeout. + """ + grace = self._INDEXING_START_GRACE_S if start_grace is None else start_grace + + # wait for progress to begin + progress_deadline = time.monotonic() + grace + while time.monotonic() < progress_deadline: + with self._progress_lock: + if self._active_progress_tokens: + break + if self._indexing_complete.is_set(): + return True + time.sleep(0.05) + + # treat absent progress as ready + with self._progress_lock: + has_active_progress = bool(self._active_progress_tokens) + if not has_active_progress: + self._indexing_complete.set() + return True + + # wait for active progress to drain + return self.wait_for_indexing(timeout=timeout) + def expect_indexing(self) -> None: """Signal that new files are about to be opened and async indexing should be awaited. @@ -123,6 +154,44 @@ class TypeScriptLanguageServer(SolidLanguageServer): """ self._indexing_complete.clear() + def describe_indexing_state(self) -> str: + """:return: compact diagnostic state for TypeScript indexing progress.""" + with self._progress_lock: + active_tokens = sorted(token for token in self._active_progress_tokens if token) + complete = self._indexing_complete.is_set() + + token_text = ", ".join(active_tokens) if active_tokens else "<none>" + return f"complete={complete}, active_progress_tokens={token_text}" + + def _get_server_ready_timeout(self) -> float: + """:return: maximum seconds to wait for the TypeScript server-ready signal.""" + return float(self._custom_settings.get("server_ready_timeout", self.SERVER_READY_TIMEOUT)) + + def _get_indexing_timeout(self) -> float: + """:return: maximum seconds to wait for TypeScript project indexing.""" + return float(self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT)) + + def _handle_server_ready_timeout(self, timeout: float) -> None: + """Handle a TypeScript server-ready timeout. + + The base TypeScript server keeps the historical permissive behavior. Strict companion + servers override this hook to fail before serving requests from a cold server. + """ + log.info("Timeout waiting for TypeScript server to become ready after %.0fs, proceeding anyway", timeout) + self.server_ready.set() + + def _handle_project_indexing_timeout(self, timeout: float) -> None: + """Handle a TypeScript project-indexing timeout. + + The base TypeScript server keeps the historical permissive behavior. Strict companion + servers override this hook to fail before serving requests from a partially indexed program. + """ + log.warning( + "TypeScript project indexing did not complete within %.0fs; proceeding anyway (%s)", + timeout, + self.describe_indexing_state(), + ) + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @@ -383,26 +452,22 @@ class TypeScriptLanguageServer(SolidLanguageServer): } self.server.notify.initialized({}) - if self.server_ready.wait(timeout=10.0): + server_ready_timeout = self._get_server_ready_timeout() + if self.server_ready.wait(timeout=server_ready_timeout): log.info("TypeScript server is ready") else: - log.info("Timeout waiting for TypeScript server to become ready, proceeding anyway") - # Fallback: assume server is ready after timeout - self.server_ready.set() + self._handle_server_ready_timeout(server_ready_timeout) # Wait for any async project loading to complete. # typescript-language-server may send $/progress for "Initializing JS/TS # language features…" after initialized. If no progress is sent, # _indexing_complete stays SET and wait() returns immediately. - indexing_timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT) + indexing_timeout = self._get_indexing_timeout() log.info("Waiting for TypeScript project indexing to complete (if async)...") if self.wait_for_indexing(timeout=indexing_timeout): log.info("TypeScript project indexing complete") else: - log.warning( - "TypeScript project indexing did not complete within %.0fs; proceeding anyway", - indexing_timeout, - ) + self._handle_project_indexing_timeout(indexing_timeout) self._activate_additional_workspaces() @@ -438,10 +503,15 @@ class TypeScriptLanguageServer(SolidLanguageServer): @override def _wait_for_additional_workspace_indexing(self) -> None: - if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT): + timeout = self._get_indexing_timeout() + if self.wait_for_indexing(timeout=timeout): log.info("Additional workspace indexing complete") else: - log.warning("Additional workspace indexing did not complete within timeout; proceeding anyway") + log.warning( + "Additional workspace indexing did not complete within %.0fs; proceeding anyway (%s)", + timeout, + self.describe_indexing_state(), + ) @override def _get_published_diagnostics_uri(self, request_uri: str) -> str: @@ -470,19 +540,14 @@ class TypeScriptLanguageServer(SolidLanguageServer): def _wait_for_cross_file_references_if_needed(self) -> None: if self._has_waited_for_cross_file_references: return - # Give tsserver a short grace period to start reporting $/progress after the file - # was opened. If progress starts, wait until it drains (bounded by indexing_timeout); - # if it never starts, tsserver considers the project loaded and we proceed after the - # grace period — which is no worse than the previous fixed 2-second wait. - grace_deadline = time.monotonic() + self._INDEXING_START_GRACE_S - while time.monotonic() < grace_deadline and not self._indexing_complete.is_set() and not self._active_progress_tokens: - time.sleep(0.05) - if self._active_progress_tokens: - timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT) - if self.wait_for_indexing(timeout=timeout): - log.info("TypeScript cross-file indexing complete") - else: - log.warning("TypeScript cross-file indexing did not complete within %.0fs; proceeding", timeout) + + timeout = self._get_indexing_timeout() + if self._wait_for_indexing_start_or_completion(timeout=timeout): + log.info("TypeScript cross-file indexing complete") + else: + log.warning( + "TypeScript cross-file indexing did not complete within %.0fs; proceeding (%s)", timeout, self.describe_indexing_state() + ) self._has_waited_for_cross_file_references = True @override diff --git a/test/resources/repos/svelte/test_repo/.gitignore b/test/resources/repos/svelte/test_repo/.gitignore index fdfcaa3b..1e7ac1f2 100644 --- a/test/resources/repos/svelte/test_repo/.gitignore +++ b/test/resources/repos/svelte/test_repo/.gitignore @@ -23,6 +23,5 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* # Lock files -package-lock.json pnpm-lock.yaml -yarn.lock \ No newline at end of file +yarn.lock diff --git a/test/resources/repos/svelte/test_repo/package-lock.json b/test/resources/repos/svelte/test_repo/package-lock.json new file mode 100644 index 00000000..5fa3a17a --- /dev/null +++ b/test/resources/repos/svelte/test_repo/package-lock.json @@ -0,0 +1,1864 @@ +{ + "name": "test-repo", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-repo", + "version": "0.0.1", + "devDependencies": { + "@fontsource/fira-mono": "^5.2.7", + "@neoconfetti/svelte": "^2.2.2", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.6.1", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fontsource/fira-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/fira-mono/-/fira-mono-5.2.7.tgz", + "integrity": "sha512-wYrAn6i3nH6luqQBZxtWUpl4UTUvs9AEbEeZxksPMwIqyjRRaxHTNW3c2VfM50gabS2IS7pT8lVWS2USB4ukYA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@neoconfetti/svelte": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@neoconfetti/svelte/-/svelte-2.2.2.tgz", + "integrity": "sha512-E7xCFVEEm5Ctnj2udTJy1b9oaTvjz1zi1mYdEtE8rB5BVwq6kHisosDS+zdWN5PMfEMjtbsOV9Cl6tsNSAD1sA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", + "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", + "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/solidlsp/svelte/conftest.py b/test/solidlsp/svelte/conftest.py index 24b5a836..1b6183b1 100644 --- a/test/solidlsp/svelte/conftest.py +++ b/test/solidlsp/svelte/conftest.py @@ -13,41 +13,79 @@ log = logging.getLogger(__name__) repo_path = Path(__file__).resolve().parents[2] / "resources" / "repos" / "svelte" / "test_repo" NODE_MODULES = repo_path / "node_modules" +PACKAGE_LOCK = repo_path / "package-lock.json" SVELTE_MARKER = NODE_MODULES / "svelte" / "package.json" SVELTE_KIT_ADAPTER_MARKER = NODE_MODULES / "@sveltejs" / "adapter-auto" / "package.json" +SVELTE_KIT_TSCONFIG = repo_path / ".svelte-kit" / "tsconfig.json" INSTALL_LOCK = repo_path / ".svelte-install.lock" +def _fixture_ready() -> bool: + return SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists() and SVELTE_KIT_TSCONFIG.exists() + + +def _run_svelte_kit_sync(npm_executable: str) -> None: + """Generate .svelte-kit (notably its tsconfig.json carrying the $lib path aliases), failing loudly on error. + + The fixture's own ``prepare`` script masks sync failures (``svelte-kit sync || echo ''``); a missing + ``.svelte-kit/tsconfig.json`` leaves the fixture tsconfig's ``extends`` dangling, tsserver silently loses + the ``$lib`` path aliases, and cross-file tests fail with partial results that look like LS flakes. + """ + sync = subprocess.run( + [npm_executable, "exec", "--", "svelte-kit", "sync"], + cwd=str(repo_path), + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + if sync.returncode != 0 or not SVELTE_KIT_TSCONFIG.exists(): + pytest.fail( + f"svelte-kit sync failed (rc={sync.returncode}) or did not produce {SVELTE_KIT_TSCONFIG}; " + "without it the $lib path aliases do not resolve and cross-file svelte tests fail with partial results.\n" + "Known cause: npm silently skipping platform-specific optional dependencies (npm/cli#4828), which " + "leaves rolldown without its native binding; remedy: remove node_modules and reinstall.\n" + f"stdout:\n{sync.stdout}\nstderr:\n{sync.stderr}" + ) + log.info("svelte-kit sync succeeded; %s is present", SVELTE_KIT_TSCONFIG) + + @pytest.fixture(scope="session", autouse=True) def _install_svelte_test_repo_node_modules() -> None: - """Populate the Svelte fixture's project dependencies via npm.""" - if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists(): - log.info("Svelte test repo node_modules already populated; skipping npm install") + """Populate the Svelte fixture's project dependencies via npm and generate .svelte-kit.""" + if _fixture_ready(): + log.info("Svelte test repo node_modules and .svelte-kit already populated; skipping npm install") return npm_executable = shutil.which("npm.cmd") or shutil.which("npm") if npm_executable is None: pytest.skip("npm is not available; cannot install Svelte test repo dependencies") + if not PACKAGE_LOCK.exists(): + pytest.fail(f"Svelte fixture lockfile is missing: {PACKAGE_LOCK}. Regenerate it before running npm ci.") + with FileLock(str(INSTALL_LOCK)): - if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists(): - log.info("Svelte test repo node_modules populated by another worker; skipping npm install") + if _fixture_ready(): + log.info("Svelte test repo dependencies populated by another worker; skipping npm install") return - log.warning("Installing npm dependencies into the Svelte test repo at %s.", repo_path) - proc = subprocess.run( - [npm_executable, "install"], - cwd=str(repo_path), - capture_output=True, - text=True, - check=False, - env=os.environ.copy(), - ) - if proc.returncode != 0: - log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr) - pytest.skip(f"npm install failed in {repo_path} (rc={proc.returncode}); see logs for details") - if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists(): - pytest.skip("npm install completed but required Svelte fixture packages are missing") + log.warning("Installing npm dependencies into the Svelte test repo at %s with npm ci.", repo_path) + proc = subprocess.run( + [npm_executable, "ci"], + cwd=str(repo_path), + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + if proc.returncode != 0: + log.error("npm ci failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr) + pytest.skip(f"npm ci failed in {repo_path} (rc={proc.returncode}); see logs for details") - log.info("Svelte test repo node_modules installed successfully") + if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists(): + pytest.skip("npm ci completed but required Svelte fixture packages are missing") + + log.info("Svelte test repo node_modules installed successfully") + + _run_svelte_kit_sync(npm_executable) diff --git a/test/solidlsp/svelte/test_svelte_basic.py b/test/solidlsp/svelte/test_svelte_basic.py index e49ebd0b..7218cc0f 100644 --- a/test/solidlsp/svelte/test_svelte_basic.py +++ b/test/solidlsp/svelte/test_svelte_basic.py @@ -76,9 +76,10 @@ class TestSvelteLanguageServer: coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)") definitions = language_server.request_definition(file_path, coords.line, coords.col) + definition_paths = sorted(definition["relativePath"].replace("\\", "/") for definition in definitions) - assert len(definitions) == 1, definitions - assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte" + assert len(definitions) == 1, definition_paths + assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte", definition_paths @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) def test_diagnostics_in_typescript_file(self, language_server: SolidLanguageServer) -> None: diff --git a/test/solidlsp/svelte/test_svelte_references.py b/test/solidlsp/svelte/test_svelte_references.py index 7e25597c..07630c8d 100644 --- a/test/solidlsp/svelte/test_svelte_references.py +++ b/test/solidlsp/svelte/test_svelte_references.py @@ -14,13 +14,13 @@ class TestSvelteReferences: refs = language_server.request_references(os.path.join("src", "lib", "components", "Words.svelte"), 1, 17) ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} - assert "src/routes/(sverdle)/words.server.ts" in ref_paths - assert "src/lib/game.ts" in ref_paths - assert "src/routes/(sverdle)/+page.svelte" in ref_paths + assert "src/routes/(sverdle)/words.server.ts" in ref_paths, sorted(ref_paths) + assert "src/lib/game.ts" in ref_paths, sorted(ref_paths) + assert "src/routes/(sverdle)/+page.svelte" in ref_paths, sorted(ref_paths) @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) def test_references_from_typescript_file(self, language_server: SolidLanguageServer) -> None: refs = language_server.request_references(os.path.join("src", "lib", "game.ts"), 3, 13) ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} - assert "src/routes/(sverdle)/+page.server.ts" in ref_paths + assert "src/routes/(sverdle)/+page.server.ts" in ref_paths, sorted(ref_paths) diff --git a/test/solidlsp/test_typescript_timeout_policy.py b/test/solidlsp/test_typescript_timeout_policy.py new file mode 100644 index 00000000..f88ae17d --- /dev/null +++ b/test/solidlsp/test_typescript_timeout_policy.py @@ -0,0 +1,269 @@ +"""Regression tests for the TypeScript/Svelte timeout policy split. + +The base TypeScript server keeps its historical permissive behavior on readiness and +indexing timeouts (log and proceed), while the Svelte companion TS server is strict +(raise instead of serving requests from a cold or partially indexed program). These +tests pin that policy and the settings plumbing directly, without spawning language +server processes, so a future refactor cannot silently revert either side. +""" + +import threading +import time + +import pytest + +from solidlsp.language_servers.svelte_language_server import ( + SvelteCompanionPreparationError, + SvelteLanguageServer, + SvelteTypeScriptServer, +) +from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer +from solidlsp.settings import SolidLSPSettings + + +def _bare_ts_server(cls: type[TypeScriptLanguageServer], custom_settings: dict | None = None) -> TypeScriptLanguageServer: + """Create an instance without running __init__ (no process, no repo scan), setting only the + state the timeout machinery touches; same technique as test_rename_didopen.py. + """ + server = object.__new__(cls) + server.server_ready = threading.Event() + server._progress_lock = threading.Lock() + server._active_progress_tokens = set() + server._indexing_complete = threading.Event() + server._indexing_complete.set() # mirrors __init__: initially no active work + server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings) + return server + + +class TestBaseTypeScriptTimeoutPolicy: + """The base server must stay permissive: plain TypeScript setups are unchanged.""" + + def test_server_ready_timeout_proceeds(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + assert not server.server_ready.is_set() + + server._handle_server_ready_timeout(10.0) # must not raise + + assert server.server_ready.is_set() + + def test_indexing_timeout_proceeds(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("indexing-1") + + server._handle_project_indexing_timeout(30.0) # must not raise + + def test_timeout_defaults(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + assert server._get_server_ready_timeout() == 10.0 + assert server._get_indexing_timeout() == 30.0 + + def test_timeouts_configurable_via_ls_specific_settings(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer, {"server_ready_timeout": 1.5, "indexing_timeout": 2.5}) + assert server._get_server_ready_timeout() == 1.5 + assert server._get_indexing_timeout() == 2.5 + + +class TestSvelteCompanionTimeoutPolicy: + """The Svelte companion must be strict: raise instead of serving from a cold/partial program.""" + + def test_server_ready_timeout_raises(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + + with pytest.raises(TimeoutError, match="did not become ready within 30s"): + server._handle_server_ready_timeout(30.0) + + assert not server.server_ready.is_set() # a strict server must not fake readiness + + def test_indexing_timeout_raises_with_diagnostic_state(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + server.expect_indexing() + server._active_progress_tokens.add("initializing-js-ts-features") + + with pytest.raises(TimeoutError) as exc_info: + server._handle_project_indexing_timeout(120.0) + + message = str(exc_info.value) + assert "did not complete within 120s" in message + assert "complete=False" in message + assert "initializing-js-ts-features" in message + + def test_companion_timeout_defaults_are_raised(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + assert server._get_server_ready_timeout() == 30.0 + assert server._get_indexing_timeout() == 120.0 + + +class TestWaitForIndexingStartOrCompletion: + def test_returns_false_when_active_progress_never_completes(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("stuck-progress") + + assert server._wait_for_indexing_start_or_completion(timeout=0.1, start_grace=0.0) is False + + def test_treats_absent_progress_as_ready(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True + assert server._indexing_complete.is_set() + + def test_returns_immediately_when_no_indexing_expected(self) -> None: + # _indexing_complete is initially set; a long grace must not block in that case + server = _bare_ts_server(TypeScriptLanguageServer) + + start = time.monotonic() + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=30.0) is True + # must return via the early is_set() check, not sit out the 30s grace; the path is + # pure in-memory, so a 10s bound cannot flake on a slow runner + assert time.monotonic() - start < 10.0 + + def test_returns_true_when_progress_completes(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("indexing-1") + + def finish_indexing() -> None: + with server._progress_lock: + server._active_progress_tokens.clear() + server._indexing_complete.set() + + timer = threading.Timer(0.05, finish_indexing) + timer.start() + try: + # both timer orderings pass: an early fire takes the absent-progress branch, + # which sets the event itself, while a late fire wakes Event.wait; ordering + # cannot flake this test. It fails only if the spawned thread does not run + # within the 30s ceiling, which means the runner is hung. + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True + finally: + timer.cancel() + + +class _FakeSolidLSPSettings: + """Minimal settings stand-in: returns a fixed TypeScript indexing_timeout so the guard tests can + prove the svelte-key value takes precedence over the typescript-key value. + """ + + def __init__(self, ts_indexing_timeout: float) -> None: + self._ts_indexing_timeout = ts_indexing_timeout + + def get_ls_specific_settings(self, language: object) -> "SolidLSPSettings.CustomLSSettings": + return SolidLSPSettings.CustomLSSettings({"indexing_timeout": self._ts_indexing_timeout}) + + +class _FakeCompanionTSServer: + """Hand-written stand-in for the companion TS server that records calls, so the guard tests assert + observable behavior (raised errors, message shape, which timeout is used) instead of mock interactions. + """ + + class _FileBuffer: + def __init__(self, uri: str) -> None: + self.uri = uri + self.ref_count = 0 + + def __enter__(self) -> "_FakeCompanionTSServer._FileBuffer": + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def __init__(self, *, wait_result: bool = True, open_error: Exception | None = None) -> None: + self.calls: list[str] = [] + self.wait_timeout: float | None = None + self._wait_result = wait_result + self._open_error = open_error + + def expect_indexing(self) -> None: + self.calls.append("expect_indexing") + + def open_file(self, relative_path: str) -> "_FakeCompanionTSServer._FileBuffer": + self.calls.append("open_file") + if self._open_error is not None: + raise self._open_error + return self._FileBuffer(uri=f"file:///{relative_path}") + + def _wait_for_indexing_start_or_completion(self, timeout: float) -> bool: + self.calls.append("wait") + self.wait_timeout = timeout + return self._wait_result + + def describe_indexing_state(self) -> str: + return "complete=False, active_progress_tokens=stuck-progress" + + +class TestSvelteLanguageServerCompanionGuard: + """SvelteLanguageServer._ensure_svelte_files_indexed_on_ts_server must fail loudly, not degrade.""" + + def _bare_svelte_ls(self, ts_server: _FakeCompanionTSServer, repo_path: str) -> SvelteLanguageServer: + ls = object.__new__(SvelteLanguageServer) + ls.repo_path = repo_path + ls._svelte_files_indexed = False + ls._indexed_svelte_file_uris = [] + ls._custom_settings = SolidLSPSettings.CustomLSSettings({"indexing_timeout": 0.01}) + # conflicting typescript-key value: the timeout assertions below prove the svelte key wins + ls._solidlsp_settings = _FakeSolidLSPSettings(ts_indexing_timeout=999.0) + ls._ts_server = ts_server + return ls + + def test_raises_when_companion_indexing_times_out(self, tmp_path) -> None: + (tmp_path / "App.svelte").touch() + ts_server = _FakeCompanionTSServer(wait_result=False) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(TimeoutError, match="did not finish indexing 1 .svelte files"): + ls._ensure_svelte_files_indexed_on_ts_server() + + # the svelte-key indexing_timeout (0.01), not the typescript-key (999.0), must reach the wait + assert ts_server.wait_timeout == 0.01 + + def test_succeeds_and_arms_progress_tracking_before_opening_files(self, tmp_path) -> None: + (tmp_path / "App.svelte").touch() + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "skip.svelte").touch() + (tmp_path / ".hidden").mkdir() + (tmp_path / ".hidden" / "skip.svelte").touch() + ts_server = _FakeCompanionTSServer(wait_result=True) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + ls._ensure_svelte_files_indexed_on_ts_server() + + assert ts_server.calls.count("open_file") == 1 + # a second call is a no-op — the observable consequence of the indexed flag + calls_after_indexing = list(ts_server.calls) + ls._ensure_svelte_files_indexed_on_ts_server() + assert ts_server.calls == calls_after_indexing + # expect_indexing must be armed BEFORE any file is opened, or early progress is lost to a race + assert ts_server.calls[0] == "expect_indexing" + assert ts_server.calls.index("expect_indexing") < ts_server.calls.index("open_file") + + def test_collects_failed_opens_and_raises_preparation_error(self, tmp_path) -> None: + (tmp_path / "B.svelte").touch() + (tmp_path / "A.svelte").touch() + ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed")) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(SvelteCompanionPreparationError, match="2 Svelte file") as exc_info: + ls._ensure_svelte_files_indexed_on_ts_server() + + assert "A.svelte, B.svelte" in str(exc_info.value) + # the first underlying error must be chained so the cause survives without DEBUG logs + assert isinstance(exc_info.value.__cause__, RuntimeError) + # a failed preparation must short-circuit before the indexing wait + assert "wait" not in ts_server.calls + + def test_preparation_error_caps_file_listing(self, tmp_path) -> None: + for i in reversed(range(15)): + (tmp_path / f"{i:02d}.svelte").touch() + ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed")) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(SvelteCompanionPreparationError) as exc_info: + ls._ensure_svelte_files_indexed_on_ts_server() + + message = str(exc_info.value) + assert "15 Svelte file(s)" in message + assert "09.svelte" in message # the 10th entry of the sorted listing is still shown + assert "10.svelte" not in message # the 11th is capped + assert "and 5 more" in message From 1901e63dc45d1f063741bf001067ac52391271be Mon Sep 17 00:00:00 2001 From: Amir Fathi <amirfathi.me@gmail.com> Date: Sun, 12 Jul 2026 03:05:13 -0600 Subject: [PATCH 102/170] fix(search): glob '?' matches one character, not two (#1682) --- CHANGELOG.md | 3 +++ src/serena/util/text_utils.py | 2 +- test/serena/test_text_utils.py | 17 ++++++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78703962..1ce07eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: + - Fix: in `glob_to_regex` / `search_text(is_glob=True)`, a `?` wildcard matched two characters instead + of one (it emitted `..` rather than `.`); it now matches a single character, consistent with the + `?` semantics documented for `glob_match` and the `test_??.py` example in `search_text`'s docstring. - Add notion of trusted projects via new global configuration setting `trusted_project_path_patterns`. Current effects: - `ls_specific_settings` defined in project configurations will only be applied for trusted projects diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 67110f70..992c2437 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -124,7 +124,7 @@ def glob_to_regex(glob_pat: str) -> str: if ch == "*": regex_parts.append(".*") elif ch == "?": - regex_parts.append("..") + regex_parts.append(".") elif ch == "\\": i += 1 if i < len(glob_pat): diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 71b0aad6..936779fc 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -1,9 +1,10 @@ +import re from collections.abc import Callable import pytest from serena.util.file_proxy import FileCollection, FileProxy -from serena.util.text_utils import LineType, MultiFileContentReplacer, search_files, search_text +from serena.util.text_utils import LineType, MultiFileContentReplacer, glob_to_regex, search_files, search_text class TestSearchText: @@ -201,6 +202,20 @@ class TestSearchText: assert len(matches_curly) == 1 assert "{bar}" in matches_curly[0].lines[0].line_content + def test_glob_to_regex_question_matches_single_char(self): + """A glob '?' matches exactly one character, matching glob_match's documented semantics.""" + rx = glob_to_regex("a?c") + assert re.fullmatch(rx, "abc") is not None + assert re.fullmatch(rx, "ac") is None + assert re.fullmatch(rx, "abbc") is None + + def test_search_text_glob_question_single_char(self): + """search_text('c?t', is_glob=True) must match 'cat' (one char), not 'coat' (two chars).""" + content = "value_cat_end\nvalue_coat_end" + matches = search_text("c?t", content=content, is_glob=True, multiline=False) + assert len(matches) == 1 + assert "value_cat_end" in matches[0].lines[0].line_content + def test_search_text_no_matches(self): """Test searching with a pattern that doesn't match anything.""" content = """ From c933fd8ec2f4936040bc10768525afed230a017d Mon Sep 17 00:00:00 2001 From: xor <127287135+xormania@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:18:27 -0400 Subject: [PATCH 103/170] SolidLSP: classify missing paths in is_ignored_path instead of raising (#1673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Language servers can report locations of generated files that are not on disk. JDTLS does this for Lombok-generated types, reporting e.g. target/classes/test_repo/LombokModel$LombokModelBuilder.class while indexing; is_ignored_path then raised FileNotFoundError, turning an intermittent indexing state into a hard test failure (test_bare_symbol_names[java]; Tests run #4240, job jvm (macos-latest)). Changes: - is_ignored_path classifies missing paths with the usual lexical rules instead of raising: ignored-dirname components, the unsupported-extension rule, then the configured pathspec. A missing path is treated as a file if its name has a suffix and as a directory otherwise (so the leaf of a missing suffixless path is also checked against ignored dirnames). Behavior for existing paths is unchanged: the checks were reordered (ignored-dirname before unsupported-extension), but each check is pure, so the outcomes are identical. - SymbolLocationRequest.convert_location_item skips locations whose absolute path does not exist (log + return None), so phantom build artifacts never reach consumers regardless of ignore configuration. - New unit tests (test double, no language server process; they run in the catch-all CI batch) pin the missing-path classification — including the exact Lombok .class path shape from the CI failure — and the skipping of symbol locations that are not on disk. Notes: - The removed raise arrived verbatim with the multilspy import (2f607ff2) and was never a deliberate guard. - The Project layer's equivalent check already tolerates missing paths (9c19d5f7), but returns False unconditionally. The LS layer instead classifies lexically so that LS-reported build artifacts still classify as ignored: with #1645 having removed the JDTLS build-dirname ignores, it is the unsupported-extension rule that catches *.class paths. --- CHANGELOG.md | 4 + src/solidlsp/ls.py | 37 ++-- test/solidlsp/test_is_ignored_path_missing.py | 196 ++++++++++++++++++ 3 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 test/solidlsp/test_is_ignored_path_missing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df4879d3..1c2a62b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,10 @@ Status of the `main` branch. Changes prior to the next official version change w - Make tool call errors surface explicitly as errors at the MCP protocol level * Language Servers: + - Fix: `SolidLanguageServer.is_ignored_path` raised `FileNotFoundError` for paths that are not present + on disk, crashing symbol tools when a language server reported locations of generated files + (e.g. JDTLS reporting Lombok-generated classes under `target/classes`). Missing paths are now + classified by the usual ignore rules, and symbol locations resolving to non-existent files are skipped. - Solidity: fix diagnostics intermittently coming back empty on slow or cold environments (macOS/Windows CI). - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 73e1bfc3..57e44489 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1367,26 +1367,27 @@ class SolidLanguageServer(ABC): Determine if a path should be ignored based on file type and ignore patterns. + Missing paths are classified without raising, since language servers may report + locations of generated files that are not present on disk (e.g. compiled classes + under build output). A missing path is treated as a file if its name has a suffix + and as a directory otherwise; directory-only ignore patterns (e.g. ``build/``) + match only existing directories. + :param relative_path: Relative path to check :param ignore_unsupported_files: whether files that are not supported source files should be ignored :return: True if the path should be ignored, False otherwise """ abs_path = os.path.join(self.repository_root_path, relative_path) - if not os.path.exists(abs_path): - raise FileNotFoundError(f"File {abs_path} not found, the ignore check cannot be performed") - - # Check file extension if it's a file - is_file = os.path.isfile(abs_path) - if is_file and ignore_unsupported_files: - fn_matcher = self.get_source_fn_matcher() - if not fn_matcher.is_relevant_filename(abs_path): - return True - - # Create normalized path for consistent handling rel_path = Path(relative_path) - # Check each part of the path against always fulfilled ignore conditions + # determine whether the path is a file: from the filesystem if the path exists, lexically otherwise + exists = os.path.exists(abs_path) + if not exists: + log.debug("Path %s does not exist; the ignore check is performed on the path itself", abs_path) + is_file = os.path.isfile(abs_path) if exists else bool(rel_path.suffix) + + # check each directory component against always-ignored dirnames dir_parts = rel_path.parts if is_file: dir_parts = dir_parts[:-1] @@ -1396,6 +1397,12 @@ class SolidLanguageServer(ABC): if self.is_ignored_dirname(part): return True + # apply the unsupported-extension rule to files + if is_file and ignore_unsupported_files: + fn_matcher = self.get_source_fn_matcher() + if not fn_matcher.is_relevant_filename(abs_path): + return True + return match_path(relative_path, self.get_ignore_spec(), root_path=self.repository_root_path) @contextmanager @@ -1667,6 +1674,12 @@ class SolidLanguageServer(ABC): ) return None + # skip locations of generated files that are not present on disk + # (language servers may report e.g. compiled classes under build output) + if not os.path.exists(abs_path): + log.info("%s found symbol at non-existent path: %s", self.request_name, abs_path) + return None + if self.skip_ignored_paths and self.language_server.is_ignored_path(rel_path_str): log.info("%s found symbol in ignored path: %s", self.request_name, rel_path_str) return None diff --git a/test/solidlsp/test_is_ignored_path_missing.py b/test/solidlsp/test_is_ignored_path_missing.py new file mode 100644 index 00000000..56afc5e7 --- /dev/null +++ b/test/solidlsp/test_is_ignored_path_missing.py @@ -0,0 +1,196 @@ +"""Unit tests: missing-path handling in ``SolidLanguageServer``. + +``is_ignored_path`` must not raise on missing paths, and symbol locations resolving to +non-existent files must be skipped. Language servers may report locations of generated +files that are not present on disk, e.g. JDTLS reporting a Lombok-generated +``LombokModel$LombokModelBuilder.class`` under ``target/classes``; the ignore check must +classify such paths instead of raising ``FileNotFoundError``. + +No language markers: these use a local test double and run in catch-all. +""" + +from __future__ import annotations + +from pathlib import Path + +import pathspec +import pytest + +from solidlsp import SolidLanguageServer, ls_types +from solidlsp.ls_config import Language +from solidlsp.ls_utils import PathUtils + + +class _IgnoredPathServer(SolidLanguageServer): + """Minimal test double exposing only :meth:`is_ignored_path` dependencies.""" + + def __init__( + self, + root: Path, + language: Language, + *, + ignored_dirnames: tuple[str, ...] = (), + ignore_lines: tuple[str, ...] = (), + ) -> None: + self.repository_root_path = str(root) + self.language = language + self._ignored_dirnames = frozenset(ignored_dirnames) + self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, ignore_lines) + + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + def _start_server(self) -> None: + raise AssertionError("The test double must not start a language server") + + def _create_base_initialize_params(self) -> dict[str, object]: + raise AssertionError("The test double must not build initialize params") + + +def test_missing_lombok_class_under_target_is_ignored_not_raised(tmp_path: Path) -> None: + """JDTLS-style missing build artifact under target/classes -- must be ignored, never raise.""" + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + missing = "target/classes/test_repo/LombokModel$LombokModelBuilder.class" + + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_unsupported_extension_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/foo.pyc" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_source_file_not_in_ignored_dir_is_not_ignored(tmp_path: Path) -> None: + """A missing source path classifies as not-ignored.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/app.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is False + + +def test_missing_source_file_under_ignored_dirname_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + missing = "generated/app.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_directory_with_ignored_dirname_leaf_is_ignored(tmp_path: Path) -> None: + """The leaf of a missing suffixless path may denote a directory and is checked against ignored dirnames.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + assert ls.is_ignored_path("generated", ignore_unsupported_files=False) is True + assert ls.is_ignored_path("src/generated", ignore_unsupported_files=False) is True + assert ls.is_ignored_path(".venv", ignore_unsupported_files=False) is True + + +def test_missing_extensionless_path_is_treated_as_directory(tmp_path: Path) -> None: + """A missing suffixless path is not subject to the unsupported-extension rule for files.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + assert ls.is_ignored_path("newpkg") is False + + +def test_missing_ignored_by_pathspec_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignore_lines=("generated.py",)) + missing = "generated.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_unsupported_extension_can_be_allowed(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/foo.pyc" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing, ignore_unsupported_files=False) is False + + +def test_existing_path_under_ignored_dirname_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + source_file = tmp_path / "generated" / "app.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + assert ls.is_ignored_path("generated/app.py") is True + + +def test_existing_source_file_is_not_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + src = tmp_path / "pkg" / "mod.py" + src.parent.mkdir(parents=True) + src.write_text("x = 1\n") + assert ls.is_ignored_path("pkg/mod.py") is False + + +@pytest.mark.parametrize( + "rel", + [ + "target/classes/test_repo/LombokModel$LombokModelBuilder.class", + "build/classes/Foo.class", + "out/production/Foo.class", + ], +) +def test_java_build_output_paths_never_raise(tmp_path: Path, rel: str) -> None: + """Compiled-class paths classify as ignored via the unsupported-extension rule, present or not + (build dirnames are deliberately not hard-ignored for Java). + """ + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + # neither present nor absent should raise + assert ls.is_ignored_path(rel) is True + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"") + assert ls.is_ignored_path(rel) is True + + +class _StubSymbolLocationRequest(SolidLanguageServer.SymbolLocationRequest): + """Minimal concrete subclass exposing only :meth:`convert_location_item` dependencies.""" + + def send_request(self) -> object | None: + raise AssertionError("The test double must not send LSP requests") + + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + raise AssertionError("The test double must not normalize LSP responses") + + +def _location_request(ls: SolidLanguageServer) -> SolidLanguageServer.SymbolLocationRequest: + return _StubSymbolLocationRequest(ls, "src/app.py", 0, 0, request_name="test_request") + + +def _location_item(path: Path) -> dict: + zero = {"line": 0, "character": 0} + return {"uri": PathUtils.path_to_uri(str(path)), "range": {"start": zero, "end": zero}} + + +def test_location_at_missing_path_is_skipped(tmp_path: Path) -> None: + """Locations whose absolute path is not on disk (e.g. LS-reported build artifacts) are dropped.""" + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + missing = tmp_path / "target" / "classes" / "LombokModel$LombokModelBuilder.class" + assert not missing.exists() + assert _location_request(ls).convert_location_item(_location_item(missing)) is None + + +def test_location_at_missing_source_path_is_skipped(tmp_path: Path) -> None: + """A missing path that is not ignored is dropped by the existence check alone.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + assert ls.is_ignored_path("pkg/mod.py") is False + assert _location_request(ls).convert_location_item(_location_item(tmp_path / "pkg" / "mod.py")) is None + + +def test_location_at_existing_ignored_path_is_skipped(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + source_file = tmp_path / "generated" / "app.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + assert _location_request(ls).convert_location_item(_location_item(source_file)) is None + + +def test_location_at_existing_source_path_is_converted(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + source_file = tmp_path / "pkg" / "mod.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + location = _location_request(ls).convert_location_item(_location_item(source_file)) + assert location is not None + assert Path(location["absolutePath"]) == source_file + assert Path(location["relativePath"]) == Path("pkg/mod.py") From 837e1c249607bcf2ba589d38a51c8ce80023d7da Mon Sep 17 00:00:00 2001 From: Siddhartha Singh <siddhartha.joy@gmail.com> Date: Sun, 12 Jul 2026 15:18:07 +0530 Subject: [PATCH 104/170] preemptive fix in rust-analyzer: prevent indefinite startup hang and harden init (#1680) Hanging was never reproduced, this is preemptive hardening - Add a 120s timeout to the server_ready wait (previously waited with no timeout, so a server that crashed or never sent experimental/serverStatus with quiescent=true would hang startup forever). On timeout we log a warning and proceed, matching kotlin/matlab/powershell/etc. - Harden the language/status and experimental/serverStatus notification handlers to use dict.get() instead of raising KeyError on unexpected payloads (a KeyError in the quiescent handler could itself prevent server_ready from ever being set, feeding the hang). - Relax the over-strict completionProvider capability check: an exact-dict equality assertion crashed initialization whenever rust-analyzer changed or added a completionProvider field. These values are not used downstream, so a mismatch is now logged as a warning instead of asserted. --- .../language_servers/rust_analyzer.py | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index 65f29665..2c01ed87 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -676,9 +676,8 @@ class RustAnalyzer(SolidLanguageServer): """ def register_capability_handler(params: dict) -> None: - assert "registrations" in params - for registration in params["registrations"]: - if registration["method"] == "workspace/executeCommand": + for registration in params.get("registrations", []): + if registration.get("method") == "workspace/executeCommand": self.initialize_searcher_command_available.set() self.resolve_main_method_available.set() return @@ -687,7 +686,7 @@ class RustAnalyzer(SolidLanguageServer): # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? - if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + if params.get("type") == "ServiceReady" and params.get("message") == "ServiceReady": self.service_ready_event.set() def execute_client_command_handler(params: dict) -> list: @@ -697,7 +696,7 @@ class RustAnalyzer(SolidLanguageServer): return def check_experimental_status(params: dict) -> None: - if params["quiescent"] == True: + if params.get("quiescent") is True: self.server_ready.set() def window_log_message(msg: dict) -> None: @@ -718,13 +717,25 @@ class RustAnalyzer(SolidLanguageServer): log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) - assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore - assert "completionProvider" in init_response["capabilities"] - assert init_response["capabilities"]["completionProvider"] == { - "resolveProvider": True, - "triggerCharacters": [":", ".", "'", "("], - "completionItem": {"labelDetailsSupport": True}, - } + # Validate key server capabilities. These are sanity checks, not hard requirements: + # rust-analyzer may evolve its advertised capabilities across versions, so a mismatch is + # logged rather than asserted. (An over-strict equality check on completionProvider here + # previously crashed startup whenever upstream added or changed a completionProvider field.) + capabilities = init_response.get("capabilities", {}) if isinstance(init_response, dict) else {} + text_document_sync = capabilities.get("textDocumentSync") + change_kind = text_document_sync.get("change") if isinstance(text_document_sync, dict) else text_document_sync + if change_kind != 2: + log.warning("rust-analyzer: unexpected textDocumentSync.change=%r (expected 2 = incremental)", change_kind) + if "completionProvider" not in capabilities: + log.warning("rust-analyzer: server did not advertise a completionProvider capability") self.server.notify.initialized({}) - self.server_ready.wait() + # Wait for rust-analyzer to finish initial indexing (it emits experimental/serverStatus with + # quiescent=true when ready). Use a timeout so a server that crashes or never signals + # readiness cannot hang startup indefinitely (previously this waited with no timeout). + _SERVER_READY_TIMEOUT = 120.0 + log.info("Waiting for rust-analyzer to signal readiness (quiescent)...") + if self.server_ready.wait(timeout=_SERVER_READY_TIMEOUT): + log.info("rust-analyzer ready") + else: + log.warning("rust-analyzer did not signal readiness within %.0fs; proceeding anyway", _SERVER_READY_TIMEOUT) From 27b0747057f4e527df4045c665b9e0427fb158cf Mon Sep 17 00:00:00 2001 From: Siddhartha Singh <siddhartha.joy@gmail.com> Date: Sun, 12 Jul 2026 15:24:05 +0530 Subject: [PATCH 105/170] fix(solidlsp): correct inverted case-folding in FilenameMatcher (#1679) The code path was never used, so the bug never surfaced --- src/solidlsp/ls_config.py | 2 +- test/solidlsp/test_ls_config.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 test/solidlsp/test_ls_config.py diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index e46106e2..c1e02d7e 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -40,7 +40,7 @@ class FilenameMatcher: a *complete* extension — i.e. the extension must either end the string or be followed by a non-extension-character (anything other than a letter, digit, or underscore). """ - if self._case_sensitive: + if not self._case_sensitive: string = string.lower() for ext in self._file_extensions: if re.search(rf"{re.escape(ext)}(?:\W|$)", string): diff --git a/test/solidlsp/test_ls_config.py b/test/solidlsp/test_ls_config.py new file mode 100644 index 00000000..680bb399 --- /dev/null +++ b/test/solidlsp/test_ls_config.py @@ -0,0 +1,46 @@ +from solidlsp.ls_config import FilenameMatcher + + +class TestFilenameMatcherCaseSensitivity: + """Regression tests for FilenameMatcher case-sensitivity handling. + + ``is_relevant_filename`` and ``string_contains_relevant_filename`` must apply the same + case-folding rule: fold to lower case only when ``case_sensitive=False``. A previously inverted + check in ``string_contains_relevant_filename`` folded the input when ``case_sensitive=True`` + (and left it unfolded when ``case_sensitive=False``), so both modes could mismatch. + """ + + def test_case_insensitive_matches_uppercase_input(self) -> None: + matcher = FilenameMatcher(".py", ".pyi", case_sensitive=False) + # Uppercase / mixed-case input must match a lower-case extension when case-insensitive. + assert matcher.is_relevant_filename("FOO.PY") + assert matcher.is_relevant_filename("Foo.PyI") + assert matcher.string_contains_relevant_filename("opened FOO.PY for editing") + assert matcher.string_contains_relevant_filename("see Foo.PyI:10") + + def test_case_sensitive_respects_extension_case(self) -> None: + # R uses case-significant extensions: .R and .r are distinct. + matcher = FilenameMatcher(".R", case_sensitive=True) + assert matcher.is_relevant_filename("script.R") + assert not matcher.is_relevant_filename("script.r") + # The same must hold for the substring variant (previously inverted). + assert matcher.string_contains_relevant_filename("edited script.R just now") + assert not matcher.string_contains_relevant_filename("edited script.r just now") + + def test_two_methods_agree_on_case_folding(self) -> None: + """The two matcher methods must never disagree on a filename that ends the string.""" + for case_sensitive in (True, False): + for extensions in ((".py",), (".R", ".Rmd"), (".ts", ".tsx")): + matcher = FilenameMatcher(*extensions, case_sensitive=case_sensitive) + for candidate in ("main.py", "Main.PY", "app.R", "app.r", "x.ts", "X.TSX"): + assert matcher.is_relevant_filename(candidate) == matcher.string_contains_relevant_filename(candidate), ( + f"disagreement for {candidate!r} with extensions={extensions} case_sensitive={case_sensitive}" + ) + + def test_string_contains_requires_complete_extension(self) -> None: + """A registered extension must be a *complete* extension, not a prefix of a longer word.""" + matcher = FilenameMatcher(".py", case_sensitive=False) + assert matcher.string_contains_relevant_filename("run main.py now") + assert matcher.string_contains_relevant_filename("main.py") + # ".py" embedded in ".python" is not a complete extension occurrence. + assert not matcher.string_contains_relevant_filename("file.python") From 7b06a67509201eecec5434e107764e3fbb150855 Mon Sep 17 00:00:00 2001 From: weiconghe <13976098570@163.com> Date: Fri, 3 Jul 2026 22:49:28 +0800 Subject: [PATCH 106/170] Perl: expose file_filter/ignore_dirs via ls_specific_settings (#1449) Perl::LanguageServer only indexes files whose extension is in its `perl.fileFilter` and skips directories in `perl.ignoreDirs`. Both were hardcoded in PerlLanguageServer, so projects whose Perl source uses non-standard extensions (e.g. .cgi / .psgi web handlers, which can dominate a mature codebase) had no way to make those files visible to symbol/reference queries without forking Serena (#1449). Surface both lists through `ls_specific_settings["perl"]` (keys `file_filter` and `ignore_dirs`), defaulting to the existing values so behaviour is unchanged for current projects. The resolution lives in a pure static method `_resolve_filter_settings` so the config plumbing is covered by unit tests that don't require a Perl runtime. Keep Serena's symbol index in sync with the LS (#1449, per review): `find_symbol` and symbol indexing are driven by `Language.PERL.get_source_fn_matcher()`, so adding an extension (e.g. .cgi) to `file_filter` alone would leave the matcher untouched and the extension's symbols invisible. Since that matcher is now a @cache'd per-language singleton, add a `FilenameMatcher.add_extensions()` mutator and call it from `PerlLanguageServer` so the configured extensions also reach the symbol index, the ignore checks, and language composition. Avoid cross-project leakage (per review): because the matcher is a process-wide singleton, extensions added for one project would otherwise persist into the next. `FilenameMatcher` now snapshots its initial configuration and exposes `reset()`, and `SolidLanguageServer.__init__` resets the matcher right after `self.language` is determined, so every activation starts from the language's defaults before the subclass re-applies its own `file_filter`. This complements 860c5841 (which added .t to the default list as a surface-level fix) by addressing the root configurability gap that the maintainer acknowledged in #1449. --- CHANGELOG.md | 6 + docs/02-usage/050_configuration.md | 28 +++ .../language_servers/perl_language_server.py | 58 ++++- src/solidlsp/ls.py | 5 + src/solidlsp/ls_config.py | 33 +++ test/solidlsp/perl/__init__.py | 0 test/solidlsp/perl/test_perl_config.py | 220 ++++++++++++++++++ 7 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 test/solidlsp/perl/__init__.py create mode 100644 test/solidlsp/perl/test_perl_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index defdf57b..98f7044b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,12 @@ Status of the `main` branch. Changes prior to the next official version change w (e.g. JDTLS reporting Lombok-generated classes under `target/classes`). Missing paths are now classified by the usual ignore rules, and symbol locations resolving to non-existent files are skipped. - Solidity: fix diagnostics intermittently coming back empty on slow or cold environments (macOS/Windows CI). + - Perl: expose `file_filter` and `ignore_dirs` via `ls_specific_settings["perl"]`, so projects with + non-standard extensions (e.g. `.cgi`, `.psgi`) can make those files visible to Perl::LanguageServer. + Configured extensions are also synced into the Perl source-file matcher (now a cached singleton), + keeping `find_symbol` / symbol indexing consistent with the LS; the matcher is reset on every + language server activation so one project's reconfiguration does not leak into the next. + `FilenameMatcher` gains `add_extensions` / `reset` methods. Defaults are unchanged. #1449 - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index ec7e5f95..21813640 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -871,6 +871,34 @@ Notes: - Use the FPC compiler driver (`fpc`/`fpc.exe`), not backend compilers like `ppc386.exe`. - These settings are passed as environment variables to the pasls process. +#### Perl + +Serena uses [Perl::LanguageServer](https://metacpan.org/pod/Perl::LanguageServer) for Perl support. Install Perl and the server with `cpanm Perl::LanguageServer`; Linux and macOS only (the server does not run on Windows). + +Perl::LanguageServer only indexes files whose extension is in its `perl.fileFilter` and skips directories listed in `perl.ignoreDirs`. Both are exposed below so projects with non-standard extensions (e.g. `.cgi` / `.psgi` web handlers) can make those files visible (#1449). + +**Configuration:** + +Configure the language server via `ls_specific_settings.perl` in `serena_config.yml`: + +| Setting | Default | Description | +| -------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `file_filter` | `[".pm", ".pl", ".t"]` | File extensions (with leading dot) that Perl::LanguageServer should index, e.g. `[".pm", ".pl", ".t", ".cgi"]`. | +| `ignore_dirs` | `[".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"]` | Directory names Perl::LanguageServer should skip when indexing. | + +Example configuration: + +```yaml +ls_specific_settings: + perl: + file_filter: [".pm", ".pl", ".t", ".cgi", ".psgi"] + ignore_dirs: [".git", "blib", "local", "vendor", "cover_db"] +``` + +Notes: +- Extensions added via `file_filter` are also synced into Serena's Perl source-file matcher, so `find_symbol` and symbol indexing treat the same files as the language server. Defaults are unchanged when these keys are omitted. +- The matcher is reset on every language server activation, so one project's `file_filter` does not leak into another. + #### PHP (`Intelephense`) Serena uses Intelephense for the `php` language key. diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py index 9b785ded..7e3744b6 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -12,7 +12,7 @@ from typing import Any from overrides import override from solidlsp.ls import SolidLanguageServer -from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -20,15 +20,26 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Defaults handed to Perl::LanguageServer via its `perl.fileFilter` / `perl.ignoreDirs` settings. +# Exposed for override via `ls_specific_settings["perl"]`. Extensions added via `file_filter` are +# also synced into Language.PERL.get_source_fn_matcher() (a cached singleton) so Serena's symbol +# index and the language server agree on which files are Perl sources (#1449). +_DEFAULT_FILE_FILTER: list[str] = [".pm", ".pl", ".t"] +_DEFAULT_IGNORE_DIRS: list[str] = [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"] + class PerlLanguageServer(SolidLanguageServer): """ Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. - """ - # Keep in sync with Language.PERL.get_source_fn_matcher() in solidlsp/ls_config.py: - # extensions missing here are invisible to Perl::LanguageServer's project index. - _FILE_FILTER: list = [".pm", ".pl", ".t"] + You can pass the following entries in ``ls_specific_settings["perl"]``: + - file_filter: List of file extensions (with leading dot) that Perl::LanguageServer should + index, e.g. ``[".pm", ".pl", ".t", ".cgi"]``. Defaults to ``[".pm", ".pl", ".t"]``. + The same extensions are added to Serena's Perl source-file matcher, so ``find_symbol`` + and symbol indexing treat them consistently (see #1449). + - ignore_dirs: Directory names Perl::LanguageServer should skip when indexing. Defaults to + ``[".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"]``. + """ @staticmethod def _get_perl_version() -> str | None: @@ -101,6 +112,31 @@ class PerlLanguageServer(SolidLanguageServer): return "perl -MPerl::LanguageServer -e 'Perl::LanguageServer::run'" + @staticmethod + def _resolve_filter_settings(solidlsp_settings: SolidLSPSettings) -> tuple[list[str], list[str]]: + """Resolve the ``fileFilter`` / ``ignoreDirs`` to hand to Perl::LanguageServer. + + Reads optional overrides from ``ls_specific_settings["perl"]`` (keys ``file_filter`` and + ``ignore_dirs``); falls back to the defaults otherwise. Extracted as a pure function so the + configuration plumbing can be unit-tested without starting the language server. + """ + perl_settings = solidlsp_settings.get_ls_specific_settings(Language.PERL) + file_filter = perl_settings.get("file_filter", list(_DEFAULT_FILE_FILTER)) + ignore_dirs = perl_settings.get("ignore_dirs", list(_DEFAULT_IGNORE_DIRS)) + return file_filter, ignore_dirs + + @staticmethod + def _sync_source_fn_matcher(file_filter: list[str]) -> None: + """Keep Serena's Perl source-file matcher in sync with the LS ``fileFilter``. + + ``Language.PERL.get_source_fn_matcher()`` is a ``@cache``d per-language singleton, so adding + the configured extensions here propagates to every consumer of the matcher — symbol index + traversal (``project.is_ignored_path``), the LS ignore check (``ls.is_ignored_path``), and + language composition detection. Without this, ``find_symbol`` would not surface symbols in + files whose extensions were added to ``file_filter`` (#1449). + """ + Language.PERL.get_source_fn_matcher().add_extensions(*file_filter) + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Setup runtime dependencies before initializing perl_ls_cmd = self._setup_runtime_dependencies() @@ -109,6 +145,10 @@ class PerlLanguageServer(SolidLanguageServer): config, repository_root_path, ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), "perl", solidlsp_settings ) self.request_id = 0 + self._file_filter, self._ignore_dirs = self._resolve_filter_settings(solidlsp_settings) + # Sync Serena's source-file matcher with the configured extensions so find_symbol and the + # language server agree on which files are Perl sources (see #1449). + self._sync_source_fn_matcher(self._file_filter) def _create_base_initialize_params(self) -> dict: """ @@ -153,8 +193,8 @@ class PerlLanguageServer(SolidLanguageServer): perl_config = { "perlInc": [self.repository_root_path, "."], - "fileFilter": self._FILE_FILTER, - "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], + "fileFilter": self._file_filter, + "ignoreDirs": self._ignore_dirs, } return [perl_config] @@ -189,8 +229,8 @@ class PerlLanguageServer(SolidLanguageServer): "settings": { "perl": { "perlInc": [self.repository_root_path, "."], - "fileFilter": self._FILE_FILTER, - "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], + "fileFilter": self._file_filter, + "ignoreDirs": self._ignore_dirs, } } } diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 57e44489..f296fee6 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -699,6 +699,11 @@ class SolidLanguageServer(ABC): """ identifies the language server (not to be confused with the language id passed to the language server) """ + # The source filename matcher is a @cache'd per-language singleton. A previous project may + # have extended it (e.g. Perl's file_filter adding .cgi); reset it here so every activation + # starts from the language's default extensions, then language-server subclasses re-apply + # their own settings during the rest of __init__. + self.language.get_source_fn_matcher().reset() self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {} self._published_diagnostics_generation_by_uri: dict[str, int] = {} self._published_diagnostics_generation = 0 diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index c1e02d7e..8d27c6e3 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -26,6 +26,38 @@ class FilenameMatcher: """ self._file_extensions = list(set(file_extensions)) if case_sensitive else list(set(ext.lower() for ext in file_extensions)) self._case_sensitive = case_sensitive + # Snapshot of the initial configuration, used by ``reset``. Relevant for matchers that are + # per-language singletons (``Language.get_source_fn_matcher`` is ``@cache``d): extensions added + # via ``add_extensions`` for one project must not leak into the next, so the singleton is reset + # to this snapshot at every language server initialisation. + self._initial_file_extensions = list(self._file_extensions) + + def reset(self) -> None: + """ + Restore the matcher to its initial set of extensions (as provided at construction). + + Undoes any extensions added via :meth:`add_extensions`. Intended for the per-language + singleton matchers, which are reset at the start of every language server initialisation so + that a previous project's reconfiguration does not leak into a newly activated one. + """ + self._file_extensions = list(self._initial_file_extensions) + + def add_extensions(self, *file_extensions: str) -> None: + """ + Add further file extensions to this matcher (idempotent). + + This is intended for matchers that are per-language singletons, i.e. those returned by + :meth:`Language.get_source_fn_matcher` (which is ``@cache``d): extensions that a user + configures for a language server (e.g. ``.cgi`` for Perl) can be added here so that every + consumer of the matcher — symbol index traversal, ignore checks, language composition — + treats the same set of files as sources, staying in sync with the language server. + + :param file_extensions: the additional file extensions, e.g. ``.cgi`` + """ + for ext in file_extensions: + norm = ext if self._case_sensitive else ext.lower() + if norm not in self._file_extensions: + self._file_extensions.append(norm) def is_relevant_filename(self, fn: str) -> bool: if not self._case_sensitive: @@ -311,6 +343,7 @@ class Language(str, Enum): """ return self.get_ls_class().supports_implementation_request() + # NOTE: Caching results in a singleton per enum item, which is a precondition for persistent configuration of the matcher. @cache def get_source_fn_matcher(self) -> FilenameMatcher: match self: diff --git a/test/solidlsp/perl/__init__.py b/test/solidlsp/perl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/perl/test_perl_config.py b/test/solidlsp/perl/test_perl_config.py new file mode 100644 index 00000000..3b954d9d --- /dev/null +++ b/test/solidlsp/perl/test_perl_config.py @@ -0,0 +1,220 @@ +"""Configuration plumbing for ``PerlLanguageServer``'s fileFilter / ignoreDirs. + +``Perl::LanguageServer`` only indexes files whose extension is in ``perl.fileFilter`` and skips +directories listed in ``perl.ignoreDirs``; both are pushed to the LS at startup (see +``_start_server``). The defaults must stay backward-compatible, but projects with non-standard +layouts — e.g. ``.cgi`` / ``.psgi`` Perl web handlers that can dominate a mature codebase — need +a way to extend visibility (#1449). + +These tests pin the configuration plumbing without starting the language server (which would +require a Perl runtime), so they run in every environment. They also cover the source-file matcher +sync that keeps ``find_symbol`` consistent with the LS (#1449). +""" + +from pathlib import Path + +from solidlsp.language_servers.perl_language_server import ( + _DEFAULT_FILE_FILTER, + _DEFAULT_IGNORE_DIRS, + PerlLanguageServer, +) +from solidlsp.ls_config import FilenameMatcher, Language +from solidlsp.settings import SolidLSPSettings + + +def _settings(tmp_path: Path, ls_specific_settings: dict | None = None) -> SolidLSPSettings: + """A SolidLSPSettings rooted under ``tmp_path`` with optional perl overrides.""" + return SolidLSPSettings( + solidlsp_dir=str(tmp_path / ".solidlsp"), + ls_specific_settings=ls_specific_settings or {}, + ) + + +class TestResolveFilterSettings: + def test_defaults_when_no_ls_specific_settings(self, tmp_path: Path) -> None: + # Behaviour unchanged for projects that don't set ls_specific_settings at all. + file_filter, ignore_dirs = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + + assert file_filter == _DEFAULT_FILE_FILTER + assert ignore_dirs == _DEFAULT_IGNORE_DIRS + + def test_defaults_when_perl_key_absent(self, tmp_path: Path) -> None: + # ls_specific_settings configured for another language must not leak into Perl. + settings = _settings(tmp_path, {Language.PYTHON: {"something": "unrelated"}}) + + file_filter, ignore_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert file_filter == _DEFAULT_FILE_FILTER + assert ignore_dirs == _DEFAULT_IGNORE_DIRS + + def test_custom_file_filter_makes_extra_extensions_visible(self, tmp_path: Path) -> None: + # #1449: a Perl web backend must be able to surface .cgi / .psgi handlers. + custom = [".pm", ".pl", ".t", ".cgi", ".psgi"] + settings = _settings(tmp_path, {Language.PERL: {"file_filter": custom}}) + + file_filter, _ = PerlLanguageServer._resolve_filter_settings(settings) + + assert file_filter == custom + assert ".cgi" in file_filter + + def test_custom_ignore_dirs(self, tmp_path: Path) -> None: + custom = [".git", "blib", "local", "cover_db", "t"] + settings = _settings(tmp_path, {Language.PERL: {"ignore_dirs": custom}}) + + _, ignore_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert ignore_dirs == custom + + def test_both_overrides_apply_independently(self, tmp_path: Path) -> None: + file_filter = [".pm", ".pl", ".cgi"] + ignore_dirs = [".git", "vendor"] + settings = _settings( + tmp_path, + {Language.PERL: {"file_filter": file_filter, "ignore_dirs": ignore_dirs}}, + ) + + resolved_filter, resolved_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert resolved_filter == file_filter + assert resolved_dirs == ignore_dirs + + def test_default_lists_are_not_mutated_across_instances(self, tmp_path: Path) -> None: + # The resolver must copy the module-level defaults, otherwise one instance mutating its + # returned list (or the LS handlers appending to self._file_filter) would corrupt every + # subsequently created default-configured instance. + file_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + file_filter.append(".cgi") + + next_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + + assert next_filter == _DEFAULT_FILE_FILTER + assert ".cgi" not in _DEFAULT_FILE_FILTER + + +class TestFilenameMatcherAddExtensions: + def test_adds_new_extension(self) -> None: + matcher = FilenameMatcher(".pm", ".pl") + + matcher.add_extensions(".cgi") + + assert matcher.is_relevant_filename("lib/Foo.pm") + assert matcher.is_relevant_filename("web/handler.cgi") + + def test_is_idempotent(self) -> None: + # Re-adding an existing extension must not duplicate it; calling sync twice (e.g. across + # two PerlLanguageServer instances) must be a no-op. + matcher = FilenameMatcher(".pm", ".pl") + baseline = len(matcher._file_extensions) + + matcher.add_extensions(".pm", ".pl") + matcher.add_extensions(".cgi") + matcher.add_extensions(".cgi") + + assert len(matcher._file_extensions) == baseline + 1 + assert matcher.is_relevant_filename("foo.cgi") + + def test_respects_case_insensitivity(self) -> None: + matcher = FilenameMatcher(".pm", case_sensitive=False) + + matcher.add_extensions(".CGI") + + # case-insensitive matcher normalises to lower case + assert matcher.is_relevant_filename("Handler.Cgi") + assert ".cgi" in matcher._file_extensions + assert ".CGI" not in matcher._file_extensions + + +class TestFilenameMatcherReset: + def test_reset_undoes_added_extensions(self) -> None: + matcher = FilenameMatcher(".pm", ".pl") + + matcher.add_extensions(".cgi", ".psgi") + assert matcher.is_relevant_filename("h.cgi") + + matcher.reset() + + assert not matcher.is_relevant_filename("h.cgi") + assert not matcher.is_relevant_filename("h.psgi") + assert matcher.is_relevant_filename("h.pm") + + def test_reset_restores_original_after_case_insensitive_add(self) -> None: + matcher = FilenameMatcher(".PM", case_sensitive=False) + + matcher.add_extensions(".cgi") + matcher.reset() + + # original extension survives the round-trip (normalised to lower case at construction) + assert matcher._file_extensions == [".pm"] + assert not matcher.is_relevant_filename("h.cgi") + + def test_reset_is_idempotent(self) -> None: + # SolidLanguageServer.__init__ calls reset on every activation; repeated resets must be safe + # and must not shrink below the original configuration. + matcher = FilenameMatcher(".pm", ".pl") + baseline = len(matcher._file_extensions) + + matcher.add_extensions(".cgi") + matcher.reset() + matcher.reset() + + assert len(matcher._file_extensions) == baseline + + +class TestSourceFnMatcherSync: + def test_custom_file_filter_extends_perl_matcher(self, tmp_path: Path) -> None: + # #1449: find_symbol relies on Language.PERL.get_source_fn_matcher(); unless the configured + # extensions are synced into it, symbols in .cgi/.psgi files stay invisible even though the + # LS indexes them. get_source_fn_matcher() is a @cache singleton, so reset() afterwards. + matcher = Language.PERL.get_source_fn_matcher() + try: + assert not matcher.is_relevant_filename("handler.cgi") # guard: not matched by default + + file_filter, _ = PerlLanguageServer._resolve_filter_settings( + _settings(tmp_path, {Language.PERL: {"file_filter": [".pm", ".pl", ".t", ".cgi", ".psgi"]}}) + ) + PerlLanguageServer._sync_source_fn_matcher(file_filter) + + assert matcher.is_relevant_filename("lib/Foo.pm") + assert matcher.is_relevant_filename("web/handler.cgi") + assert matcher.is_relevant_filename("app.psgi") + finally: + matcher.reset() + + def test_default_file_filter_leaves_matcher_unchanged(self, tmp_path: Path) -> None: + # The default file_filter matches the existing Perl matcher extensions, so syncing it must + # be a no-op (no duplicate entries, no new matches). + matcher = Language.PERL.get_source_fn_matcher() + try: + initial = list(matcher._file_extensions) + file_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + PerlLanguageServer._sync_source_fn_matcher(file_filter) + + assert sorted(matcher._file_extensions) == sorted(initial) + finally: + matcher.reset() + + def test_reset_prevents_cross_project_leak(self, tmp_path: Path) -> None: + # The matcher is a per-language singleton shared across projects. Project A reconfigures + # file_filter (adds .cgi); when project B is activated, SolidLanguageServer.__init__ resets + # the matcher first, so project B must NOT see .cgi even though project A added it. + # Mirrors the reset-then-sync ordering of PerlLanguageServer.__init__. + matcher = Language.PERL.get_source_fn_matcher() + try: + # project A: custom file_filter with .cgi + filter_a, _ = PerlLanguageServer._resolve_filter_settings( + _settings(tmp_path, {Language.PERL: {"file_filter": [".pm", ".pl", ".t", ".cgi"]}}) + ) + PerlLanguageServer._sync_source_fn_matcher(filter_a) + assert matcher.is_relevant_filename("handler.cgi") + + # project B activates: base __init__ resets, then default config is applied (no .cgi) + matcher.reset() + filter_b, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path / "proj_b")) + PerlLanguageServer._sync_source_fn_matcher(filter_b) + + assert not matcher.is_relevant_filename("handler.cgi"), ( + "project B inherited project A's .cgi — reset did not undo the reconfiguration" + ) + assert matcher.is_relevant_filename("lib/Foo.pm") + finally: + matcher.reset() From 45d4aaa20dfb96b48760491df283f57e486f9312 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Sun, 12 Jul 2026 16:58:02 +0200 Subject: [PATCH 107/170] Use get_registered_project instead of Project.load with exception handling --- src/serena/cli.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 453546af..77d84180 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -1124,17 +1124,11 @@ class MemoryCommands(AutoRegisteringGroup): exist at the configured location). Never auto-creates the ``.serena`` directory — if no project configuration is found, the user is directed at ``serena project create``. """ - from serena.project import Project - serena_config = SerenaConfig.from_config_file() - project_path = os.path.abspath(project) - try: - proj = Project.load(project_path, serena_config=serena_config, autogenerate=False) - except FileNotFoundError as e: - raise click.UsageError( - f"{e}\nNo Serena project found at {project_path}. Create one first with:\n serena project create {project_path}" - ) from e - return proj.memory_manager + registered_project = serena_config.get_registered_project(project) + if registered_project is None: + raise click.UsageError(f"No Serena project found for '{project}'. Create one first.") + return registered_project.get_project_instance(serena_config).memory_manager @staticmethod @click.command( From 9825316c234e0e31b1ad3bb46ebc4a42dece905a Mon Sep 17 00:00:00 2001 From: weiconghe <46336277+weiconghe@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:54:23 +0800 Subject: [PATCH 108/170] feat: add QML language support via Qt's qmlls (#1635) --- .github/workflows/pytest.yml | 29 +++- CHANGELOG.md | 2 + docs/01-about/020_programming-languages.md | 2 + pyproject.toml | 1 + .../language_servers/qml_language_server.py | 128 ++++++++++++++++++ src/solidlsp/ls_config.py | 11 ++ test/conftest.py | 6 + .../qml/test_repo/src/CustomComponent.qml | 14 ++ .../repos/qml/test_repo/src/UserComponent.qml | 23 ++++ .../qml/test_repo/src/diagnostics_sample.qml | 5 + .../repos/qml/test_repo/src/main.qml | 20 +++ .../repos/qml/test_repo/src/shapes.qml | 14 ++ test/solidlsp/qml/__init__.py | 0 test/solidlsp/qml/test_qml_basic.py | 125 +++++++++++++++++ 14 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 src/solidlsp/language_servers/qml_language_server.py create mode 100644 test/resources/repos/qml/test_repo/src/CustomComponent.qml create mode 100644 test/resources/repos/qml/test_repo/src/UserComponent.qml create mode 100644 test/resources/repos/qml/test_repo/src/diagnostics_sample.qml create mode 100644 test/resources/repos/qml/test_repo/src/main.qml create mode 100644 test/resources/repos/qml/test_repo/src/shapes.qml create mode 100644 test/solidlsp/qml/__init__.py create mode 100644 test/solidlsp/qml/test_qml_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 18fd67ac..60beb1ff 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -64,9 +64,10 @@ jobs: env: MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl" MARKERS_NATIVE: "cpp or rust or go or zig or pascal or swift" # swift is macOS-only (skips elsewhere via the guard) - # other-langs: grab-bag + scripting languages + haskell. Haskell (GHC/HLS) is installed only on - # Linux -- it's too slow/expensive to build on the macOS runner -- and skips on Windows+macOS via the 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" + # 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" # 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" @@ -418,6 +419,28 @@ jobs: mv regal.exe "$HOME/bin/" echo "$HOME/bin" >> $GITHUB_PATH fi + - name: Install qmlls (QML Language Server) + # qmlls needs Qt 6.6+ for textDocument/documentSymbol (the feature these tests assert); Ubuntu + # noble's apt only has Qt 6.4.2, whose qmlls returns -32601 "method not found" for it. So install + # the official standalone build (TheQtCompanyRnD/qmlls-workflow, linked from the Qt docs) -- an 8 MB + # single binary built from recent Qt dev. Restricted to Linux (a cross-OS Qt pull isn't worth it); + # QML skips on Windows/macOS via the conftest guard. libodbc2 is its only non-bundled runtime dep. + if: matrix.batch == 'other-langs' && runner.os == 'Linux' + shell: bash + run: | + QMLLS_VERSION="0.7" + QMLLS_DIR="$HOME/.qmlls" + mkdir -p "$QMLLS_DIR" + curl -fsSL -o /tmp/qmlls.zip "https://github.com/TheQtCompanyRnD/qmlls-workflow/releases/download/${QMLLS_VERSION}/qmllanguageserver-linux-x64-${QMLLS_VERSION}.zip" + unzip -o /tmp/qmlls.zip -d "$QMLLS_DIR" >/dev/null + rm /tmp/qmlls.zip + chmod +x "$QMLLS_DIR/qmlls" + # libodbc.so.2 is qmlls's only non-bundled runtime dep; install only if absent on the image. + ldconfig -p | grep -q 'libodbc.so.2' || { sudo apt-get update && sudo apt-get install -y libodbc2; } + # Fail loudly if any shared lib is still unresolved (e.g. a missing runtime dep). + missing="$(ldd "$QMLLS_DIR/qmlls" | grep 'not found' || true)" + [ -z "$missing" ] || { echo "ERROR: qmlls has unresolved runtime libs:"; echo "$missing"; exit 1; } + echo "$QMLLS_DIR" >> "$GITHUB_PATH" - name: Cache Free Pascal Compiler if: matrix.batch == 'native' id: cache-fpc diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f7044b..0e22ce18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,8 @@ Status of the `main` branch. Changes prior to the next official version change w build output is already excluded via `.gitignore`. #1645 - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). + - Add **QML** support via Qt's [`qmlls`](https://doc.qt.io/qt-6/qtqml-tool-qmlls.html) language + server (requires Qt 6 with `qmlls`/`qmlls6` on PATH). #1381 - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. - Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd` - Add new configuration option `ls_workspace_folders` to allow indexed source folders to be specified diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index d827971f..3cbacea8 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -130,6 +130,8 @@ Some languages require additional installations or setup steps, as noted. [pyrefly](https://github.com/facebook/pyrefly) (language `python_pyrefly`), [Jedi](https://github.com/palotas/jedi-language-server) (language `python_jedi`); ty and pyrefly require `uv`/`uvx` in PATH) +* **QML** + (requires Qt 6, provides `qmlls` or `qmlls6` on PATH; see the [Qt qmlls documentation](https://doc.qt.io/qt-6/qtqml-tool-qmlls.html)) * **R** (requires installation of the `languageserver` R package) * **Ruby** diff --git a/pyproject.toml b/pyproject.toml index 7d230e03..4b1020f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -378,6 +378,7 @@ markers = [ "scss: language server running for SCSS / Sass / CSS (some-sass-language-server)", "angular: language server running for Angular (uses @angular/language-server + @angular/language-service tsserver plugin)", "ada: language server running for Ada / SPARK (uses AdaCore Ada Language Server)", + "qml: language server running for QML (uses Qt's qmlls)", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/qml_language_server.py b/src/solidlsp/language_servers/qml_language_server.py new file mode 100644 index 00000000..e1ac3031 --- /dev/null +++ b/src/solidlsp/language_servers/qml_language_server.py @@ -0,0 +1,128 @@ +""" +Provides QML specific instantiation of the LanguageServer class using Qt's qmlls. +""" + +import logging +import shutil + +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class QmlLanguageServer(SolidLanguageServer): + """ + Provides QML specific instantiation of the LanguageServer class using qmlls. + + qmlls is the official QML language server shipped with Qt 6. + It must be installed separately; see https://doc.qt.io/qt-6/qtqml-tool-qmlls.html + for installation instructions. + + The dependency provider looks for ``qmlls6`` first, then falls back to ``qmlls``. + Users can override the executable via the ``ls_path`` entry in ``ls_specific_settings.qml``. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "qml", solidlsp_settings) + + 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: + """ + Discover the qmlls executable on PATH. + + Tries ``qmlls6`` first (Qt 6+), then falls back to ``qmlls``. + + :return: path to the qmlls executable + :raises FileNotFoundError: if qmlls is neither on PATH nor provided via ``ls_path`` + """ + qmlls_binary = shutil.which("qmlls6") or shutil.which("qmlls") + if qmlls_binary is None: + raise FileNotFoundError( + "qmlls (QML language server) is not installed or not in PATH.\n" + "Please install Qt 6 and ensure 'qmlls' (or 'qmlls6') is available on your PATH.\n" + "See: https://doc.qt.io/qt-6/qtqml-tool-qmlls.html" + ) + return qmlls_binary + + def _create_launch_command(self, core_path: str) -> list[str]: + # qmlls communicates via stdio by default; no extra flags are required. + return [core_path] + + def _create_base_initialize_params(self) -> dict: + """ + Return the language-specific initialize params for the QML language server. + + ``processId``, ``rootPath``, ``rootUri`` and ``workspaceFolders`` are populated by the + default ``InitializeParamsBuilder`` and must not be set here. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start the QML language server process.""" + + 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 + + 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) + + log.info("Starting QML language server (qmlls) process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # verify server capabilities + capabilities = init_response["capabilities"] + log.info(f"QML language server capabilities: {list(capabilities.keys())}") + + self.server.notify.initialized({}) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 8d27c6e3..2aa87fdc 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -173,6 +173,11 @@ class Language(str, Enum): The editor must already be running with its built-in LSP enabled (default). Supports .gd and .gdscript files. """ + QML = "qml" + """QML language server using Qt's qmlls (or qmlls6). + Supports .qml files. Requires Qt 6 installation providing qmlls on PATH. + See https://doc.qt.io/qt-6/qtqml-tool-qmlls.html + """ # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -557,6 +562,8 @@ class Language(str, Enum): return FilenameMatcher(".ads", ".adb", ".ada", case_sensitive=False) case self.GDSCRIPT: return FilenameMatcher(".gd", ".gdscript") + case self.QML: + return FilenameMatcher(".qml") case self.HTML: return FilenameMatcher(".html", ".htm") case self.SCSS: @@ -836,6 +843,10 @@ class Language(str, Enum): from solidlsp.language_servers.godot_language_server import GodotLanguageServer return GodotLanguageServer + case self.QML: + from solidlsp.language_servers.qml_language_server import QmlLanguageServer + + return QmlLanguageServer case self.HTML: from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer diff --git a/test/conftest.py b/test/conftest.py index fc046273..6b25dec3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -254,6 +254,7 @@ Flag indicating whether the tests are running in the GitHub CI environment. is_windows = platform.system() == "Windows" is_macos = platform.system() == "Darwin" +is_linux = platform.system() == "Linux" _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { @@ -407,6 +408,11 @@ def _determine_disabled_languages() -> list[Language]: result.append(Language.REGO) if _sh.which("elm") is None and not is_ci: result.append(Language.ELM) + # qmlls is installed (standalone build; see pytest.yml) only on the Ubuntu other-langs CI batch. It is + # expected there, so a missing binary on Linux CI is NOT skipped here -- the test runs and fails loudly, + # catching a CI setup regression. On Windows/macOS CI (never installed) and off-CI without the binary it skips. + if (_sh.which("qmlls6") is None and _sh.which("qmlls") is None) and not (is_ci and is_linux): + result.append(Language.QML) # === 3. Disabled wherever the precondition is missing (including on CI) === # 3a. Platform precondition: these language servers have no native Windows support. diff --git a/test/resources/repos/qml/test_repo/src/CustomComponent.qml b/test/resources/repos/qml/test_repo/src/CustomComponent.qml new file mode 100644 index 00000000..e44ce814 --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/CustomComponent.qml @@ -0,0 +1,14 @@ +import QtQuick 2.15 + +Rectangle { + id: root + width: 200 + height: root.width + color: "green" + + Text { + id: label + text: root.width + "x" + root.height + anchors.centerIn: parent + } +} diff --git a/test/resources/repos/qml/test_repo/src/UserComponent.qml b/test/resources/repos/qml/test_repo/src/UserComponent.qml new file mode 100644 index 00000000..56b7a7b9 --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/UserComponent.qml @@ -0,0 +1,23 @@ +import QtQuick 2.15 + +Item { + width: 400 + height: 300 + + CustomComponent { + x: 50 + y: 50 + } + + Rectangle { + y: 180 + width: 200 + height: 100 + color: "yellow" + + Text { + text: "Another shape" + anchors.centerIn: parent + } + } +} diff --git a/test/resources/repos/qml/test_repo/src/diagnostics_sample.qml b/test/resources/repos/qml/test_repo/src/diagnostics_sample.qml new file mode 100644 index 00000000..38bd34e5 --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/diagnostics_sample.qml @@ -0,0 +1,5 @@ +import QtQuick 2.15 + +Item { + width: 100 + height: 100 diff --git a/test/resources/repos/qml/test_repo/src/main.qml b/test/resources/repos/qml/test_repo/src/main.qml new file mode 100644 index 00000000..3dcfc214 --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/main.qml @@ -0,0 +1,20 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +ApplicationWindow { + id: mainWindow + title: "Test Application" + width: 800 + height: 600 + + Button { + id: mainButton + text: "Click Me" + width: 100 + height: 50 + + onClicked: { + console.log("Button clicked") + } + } +} diff --git a/test/resources/repos/qml/test_repo/src/shapes.qml b/test/resources/repos/qml/test_repo/src/shapes.qml new file mode 100644 index 00000000..13a4fabb --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/shapes.qml @@ -0,0 +1,14 @@ +import QtQuick 2.15 + +Rectangle { + id: customRect + width: 200 + height: 100 + color: "lightblue" + + Text { + id: label + text: "Hello QML" + anchors.centerIn: parent + } +} diff --git a/test/solidlsp/qml/__init__.py b/test/solidlsp/qml/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/qml/test_qml_basic.py b/test/solidlsp/qml/test_qml_basic.py new file mode 100644 index 00000000..97b658a7 --- /dev/null +++ b/test/solidlsp/qml/test_qml_basic.py @@ -0,0 +1,125 @@ +""" +Basic integration tests for the QML language server (qmlls) functionality. + +These tests validate document symbols, reference search, and diagnostics +using the QML test repository. + +Requires ``qmlls6`` or ``qmlls`` on PATH (shipped with Qt 6). +""" + +import os +from pathlib import Path + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import read_repo_file +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [ + pytest.mark.qml, + pytest.mark.skipif(not language_tests_enabled(Language.QML), reason="QML tests are disabled (qmlls/qmlls6 not available)"), +] + + +class TestQmlLanguageServer: + """Test QML language server startup and basic features.""" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer) -> None: + """Test that the language server starts successfully.""" + assert language_server.is_running() + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + def test_document_symbols_main(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the main file. + + qmlls names QML objects by their type (ApplicationWindow, Button); properties such as + ``id`` and ``width`` appear as child symbols, so we assert on the component type names. + """ + file_path = os.path.join("src", "main.qml") + doc_symbols = language_server.request_document_symbols(file_path) + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + root_names = [s.get("name") for s in root_symbols if s.get("name")] + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "ApplicationWindow" in root_names, f"ApplicationWindow root missing. Roots: {root_names}" + assert "Button" in symbol_names, f"Button component missing. Symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + def test_document_symbols_shapes(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the shapes file.""" + file_path = os.path.join("src", "shapes.qml") + doc_symbols = language_server.request_document_symbols(file_path) + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + root_names = [s.get("name") for s in root_symbols if s.get("name")] + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "Rectangle" in root_names, f"Rectangle root missing. Roots: {root_names}" + assert "Text" in symbol_names, f"Text component missing. Symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.QML], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that references are found within the same file. + + CustomComponent.qml references ``root.width`` on multiple lines (declaration via + ``id: root`` on line 3 and usages on lines 5 and 10). We click on a usage of + ``root`` and verify that the other usages are found. + """ + file_path = str(repo_path / "src" / "CustomComponent.qml") + content = read_repo_file(language_server, os.path.join("src", "CustomComponent.qml")) + + # Probe at the `root` identifier in `root.width` on line 10 (0-indexed) — + # the occurrence inside the Text block. `find_text_coordinates` uses a + # capturing group around the target text so `coords` lands on `root`. + coords = find_text_coordinates(content, r"(root)\.width") + assert coords is not None, "Could not locate root.width in CustomComponent.qml" + + references = language_server.request_references(file_path, coords.line, coords.col + 1) + assert references, f"Expected non-empty references for root, got {references=}" + + # All references should be within CustomComponent.qml (no cross-file + # references to ``root``, which is scoped to this component). + ref_files = {loc["uri"].split("/")[-1] for loc in references} + assert "CustomComponent.qml" in ref_files, f"All references should be in CustomComponent.qml, got {ref_files}" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.QML], indirect=True) + def test_find_references_cross_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that references are found across files. + + CustomComponent.qml defines a QML component type that is instantiated in + UserComponent.qml. Clicking on the ``CustomComponent`` type usage in + UserComponent.qml should return at least one reference, ideally spanning + both files. + """ + file_path = str(repo_path / "src" / "UserComponent.qml") + content = read_repo_file(language_server, os.path.join("src", "UserComponent.qml")) + + # Locate the `CustomComponent` type usage in UserComponent.qml. + coords = find_text_coordinates(content, r"(CustomComponent)") + assert coords is not None, "Could not locate CustomComponent type in UserComponent.qml" + + references = language_server.request_references(file_path, coords.line, coords.col + 1) + assert references, f"Expected non-empty references for CustomComponent, got {references=}" + + ref_files = {loc["uri"].split("/")[-1] for loc in references} + assert "UserComponent.qml" in ref_files, f"Expected at least one reference in UserComponent.qml, got {ref_files}" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + """Test that diagnostics are reported for a QML file with errors. + + diagnostics_sample.qml contains an unknown property (``colorr`` instead of + ``color``) which qmlls should flag as an error or warning. + """ + assert_file_diagnostics( + language_server, + os.path.join("src", "diagnostics_sample.qml"), + (), + min_count=1, + ) From a3de03b5564d3b10aeb55aa3b50b6ae9f45b814d Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 13 Jul 2026 22:30:55 +0200 Subject: [PATCH 109/170] Fix: jet_brains_run_inspections returned incorrectly transformed data The tool incorrectly applied the pythonification, even though it returns data not only in values but also in keys --- CHANGELOG.md | 1 + .../jetbrains/jetbrains_plugin_client.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e22ce18..a7fd62a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically upon project activation - Fix: `jet_brains_list_inspections` failed when only default parameters were used #1615 + - Fix: `jet_brains_run_inspections` returned incorrectly transformed data (malforming snake-case conversion applied to all keys) * Dashboard: - Make list of trusted hosts configurable, fixing host validation introduced in v1.5.2 allowing only diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 22fa9c27..090adbdf 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -303,7 +303,15 @@ class JetBrainsPluginClient(ToStringMixin): f"{self._plugin_version}. Ask the user to update the plugin!" ) - def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]: + def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None, pythonify: bool = True) -> dict[str, Any]: + """ + :param method: the HTTP method to use ("GET" or "POST") + :param endpoint: the endpoint to call (e.g., "/findSymbol") + :param data: the data to send in the request body (for POST requests) + :param pythonify: whether to recursively "pythonify" the response object, converting all keys to snake_case. + This must not be enabled if *any* key contains variable data rather (and therefore isn't a well-defined DTO structure). + :return: the response as a dictionary + """ url = f"{self._base_url}{endpoint}" response: Response | None = None @@ -320,7 +328,11 @@ class JetBrainsPluginClient(ToStringMixin): # Try to parse JSON response try: - return self._pythonify_response(response.json()) + response_json = response.json() + if pythonify: + return self._pythonify_response(response_json) + else: + return response_json except json.JSONDecodeError: # If response is not JSON, return raw text return {"response": response.text} @@ -695,7 +707,7 @@ class JetBrainsPluginClient(ToStringMixin): request_data["startLine"] = start_line if end_line is not None: request_data["endLine"] = end_line - return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data)) + return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data, pythonify=False)) def list_inspections( self, From 98966c6a598bb96a63a6defda310b4d8c3b92616 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 13 Jul 2026 22:39:03 +0200 Subject: [PATCH 110/170] Remove incorrect DTOs for JetBrains /runInspectionsOnFile call --- src/serena/jetbrains/jetbrains_plugin_client.py | 7 +++++-- src/serena/jetbrains/jetbrains_types.py | 16 ---------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 090adbdf..eec3393c 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -686,7 +686,7 @@ class JetBrainsPluginClient(ToStringMixin): inspection_names: list[str] | None = None, start_line: int | None = None, end_line: int | None = None, - ) -> jb.RunInspectionsResponse: + ) -> dict: """ Runs IDE inspections on the given file and returns the results. @@ -695,6 +695,9 @@ class JetBrainsPluginClient(ToStringMixin): :param inspection_names: optional list of specific inspection names to run :param start_line: optional start line to restrict the inspection range :param end_line: optional end line to restrict the inspection range + :return: the inspection results as a dictionary. + NOTE: The response is currently *not* a well-defined DTO, because it stores variable data in keys. + Consequently, the `pythonify` option is disabled for this request, and the response is returned as-is. """ request_data: dict[str, Any] = { "relativePath": relative_path, @@ -707,7 +710,7 @@ class JetBrainsPluginClient(ToStringMixin): request_data["startLine"] = start_line if end_line is not None: request_data["endLine"] = end_line - return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data, pythonify=False)) + return self._make_request("POST", "/runInspectionsOnFile", request_data, pythonify=False) def list_inspections( self, diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index ce488ea2..65add4fb 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -91,22 +91,6 @@ class TypeHierarchyResponse(TypedDict): num_levels_not_included: NotRequired[int] -class InspectionResultDTO(TypedDict): - inspection_name: str - severity: str - message: str - relative_path: str - start_line: int - end_line: int - start_col: NotRequired[int] - end_col: NotRequired[int] - quick_fix_available: NotRequired[bool] - - -class RunInspectionsResponse(TypedDict): - inspections: list[InspectionResultDTO] - - class InspectionInfoDTO(TypedDict): name: str group_path: str From 30c84f15e6d0946477f9bdd720092d9bd251b882 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Tue, 14 Jul 2026 10:38:25 +0200 Subject: [PATCH 111/170] Pyright: bump timeout for waiting for initial analysis from 5s to 60s. --- CHANGELOG.md | 1 + src/solidlsp/language_servers/pyright_server.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7fd62a6..4952c3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Status of the `main` branch. Changes prior to the next official version change w the repository. #1627 - Rename configuration option `additional_workspace_folders` to `ls_additional_workspace_folders` and support the option across all language servers (previously limited to TypeScript). + - `Pyright`: bump timeout for waiting for initial analysis from 5s to 60s. * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index b808d8f7..4a8c9fbc 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -24,6 +24,8 @@ class PyrightServer(SolidLanguageServer): Contains various configurations and settings specific to Python. """ + _TIMEOUT_FOR_INITIAL_ANALYSIS = 60.0 + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a PyrightServer instance. This class is not meant to be instantiated directly. @@ -231,8 +233,8 @@ class PyrightServer(SolidLanguageServer): # Wait for Pyright to complete its initial workspace analysis # This prevents zombie processes by ensuring background tasks finish - log.info("Waiting for Pyright to complete initial workspace analysis...") - if self.analysis_complete.wait(timeout=5.0): + log.info(f"Waiting up to {self._TIMEOUT_FOR_INITIAL_ANALYSIS}s for Pyright to complete initial workspace analysis...") + if self.analysis_complete.wait(timeout=self._TIMEOUT_FOR_INITIAL_ANALYSIS): log.info("Pyright initial analysis complete, server ready") else: log.warning("Timeout waiting for Pyright analysis completion, proceeding anyway") From c6029c6fb2ba36ebea2ca923519067ba20c9fcf3 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <michael.panchenko@oraios-ai.de> Date: Tue, 14 Jul 2026 10:41:43 +0200 Subject: [PATCH 112/170] Minor fix in docs claude-desktop -> desktop-app --- docs/02-usage/030_clients.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 6241246e..d8e30397 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -397,7 +397,7 @@ Add the `serena` MCP server configuration "command": "serena", "args": [ "start-mcp-server", - "--context=claude-desktop" + "--context=desktop-app" ] } } From 286a624a6aa60a0c5b97ac2ca874845d32731e1a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 11:03:22 +0200 Subject: [PATCH 113/170] Improve dependency provider usage instructions --- .serena/memories/adding_new_language_support_guide.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index 6eeed48e..db470b94 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -49,8 +49,8 @@ To implement a new language server using the DependencyProvider pattern: final launch command - **`LanguageServerDependencyProviderSinglePath`** - Alternative to inheriting from `...BaseCommand` directly for the case - of a single core dependency (e.g., an executable or JAR file); mostly present in existing implementations - for new - implementations, prefer `...BaseCommand` + of a single core dependency (e.g., an executable or JAR file); use when the single path is not used directly as a base command + otherwise inherit from `...BaseCommand` directly - Implement `_get_or_install_core_dependency()` to return the path to the core dependency, downloading/installing it automatically if necessary - Implement `_create_launch_command(core_path)` to build the full command from the core path - Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer` @@ -60,7 +60,6 @@ To implement a new language server using the DependencyProvider pattern: - Reference implementations: `EclipseJDTLS`, `CSharpLanguageServer`, `MatlabLanguageServer` **Implementation Pointers::** - - When returning the command, prefer the list-based representation for robustness - Override `create_launch_command_env` if the launch command needs environment variables to be set (defaults to `{}` in the base implementation) You should look at at least one existing implementation of each base class to understand how they work. From 4189e8acdba9fbf095b15482a14b1918a3d19d3f Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Sun, 12 Jul 2026 16:58:27 +0200 Subject: [PATCH 114/170] Use asynchronous project language determination to speed up MCP startup When auto-generating a Serena project configuration at startup, compute the languages in a background thread and update them later to avoid delays of the MCP server startup. Where completeness of the language list matters (i.e. language server initialisation), a corresponding event is awaited to ensure completeness. --- CHANGELOG.md | 2 + src/serena/agent.py | 2 +- src/serena/config/serena_config.py | 251 ++++++++++++++++++++--------- src/serena/project.py | 9 +- 4 files changed, 184 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f7044b..3a419eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: + - Speed up MCP startup when auto-creating projects at startup (e.g. using `--project-from-cwd`) by + determining the project's languages in a background thread #1683 - Fix: in `glob_to_regex` / `search_text(is_glob=True)`, a `?` wildcard matched two characters instead of one (it emitted `..` rather than `.`); it now matches a single character, consistent with the `?` semantics documented for `glob_match` and the `test_??.py` example in `search_text`'s docstring. diff --git a/src/serena/agent.py b/src/serena/agent.py index 637e8ae1..f21d6b03 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -1300,7 +1300,7 @@ class SerenaAgent: if project_instance is not None: log.info(f"Found registered project '{project_instance.project_name}' at path {project_instance.project_root}") elif os.path.isdir(project_root_or_name): - project_instance = self.serena_config.add_project_from_path(project_root_or_name) + project_instance = self.serena_config.add_project_from_path(project_root_or_name, asynchronous_autogen=True) log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}") if project_instance is None: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 6cb2a721..50d48aa5 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -6,6 +6,7 @@ import dataclasses import os import re import shutil +import threading from collections.abc import Iterator, Sequence from copy import deepcopy from dataclasses import dataclass, field @@ -298,6 +299,25 @@ Uses $projectDir and $projectFolderName as placeholders. """ +class ProjectConfigAutoGenerationMode(Enum): + NONE = "none" + """ + no auto-generation + """ + SYNCHRONOUS = "sync" + """ + synchronous auto-generation, i.e. the configuration is fully generated before returning from the function call + """ + ASYNCHRONOUS = "async" + """ + asynchronous auto-generation, where time-consuming configuration parts (currently only the + list of programming languages) are determined in a background thread and initialised as empty + """ + + def is_autogen_enabled(self): + return self != ProjectConfigAutoGenerationMode.NONE + + @dataclass(kw_only=True) class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name: str @@ -315,7 +335,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): # internal fields which are not mapped to/from the configuration file (must start with "_") _local_override_keys: list[str] = field(default_factory=list) - # class-level constants + # class-level members SERENA_PROJECT_FILE = "project.yml" SERENA_LOCAL_PROJECT_FILE = "project.local.yml" FIELDS_WITHOUT_DEFAULTS = {"project_name", "languages"} @@ -325,10 +345,59 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): the comment normalisation strategy to use when loading/saving project configuration files. The template file must match this configuration (i.e. it must use leading comments if this is set to LEADING). """ + _async_completion_events = {} + """ + maps the object id of a ProjectConfig instance to an event which is set when the asynchronous auto-generation of + the configuration is complete (if applicable). + """ + _save_lock = threading.Lock() def _tostring_includes(self) -> list[str]: return ["project_name"] + @classmethod + def _determine_project_languages(cls, project_root: str, interactive: bool) -> list[Language]: + # determine languages automatically + log.info("Determining programming languages used in the project") + language_composition = determine_programming_language_composition(project_root) + log.info("Language composition: %s", language_composition) + if len(language_composition) == 0: + log.warning( + "No source files for supported language servers were found in %s. " + "Creating project with no configured languages. " + "Symbol-related tools (e.g. find_symbol, get_symbols_overview) will not work " + "when using the LSP backend. You can add languages later via the Serena dashboard " + "or by manually editing the project configuration.", + project_root, + ) + languages_to_use = [] + else: + # sort languages by number of files found + languages_and_percentages = sorted( + language_composition.items(), key=lambda item: (item[1], item[0].get_priority()), reverse=True + ) + # find the language with the highest percentage and enable it + top_language_pair = languages_and_percentages[0] + other_language_pairs = languages_and_percentages[1:] + languages_to_use = [top_language_pair[0]] + # if in interactive mode, ask the user which other languages to enable + if len(other_language_pairs) > 0 and interactive: + print( + "Detected and enabled main language '%s' (%.2f%% of source files)." % (top_language_pair[0].value, top_language_pair[1]) + ) + print(f"Additionally detected {len(other_language_pairs)} other language(s).\n") + print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.") + print(" Additional language servers use resources and some languages may require additional") + print(" system-level installations/configuration (see Serena documentation).") + print("\nWhich additional languages do you want to enable?") + for lang, perc in other_language_pairs: + enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang.value, perc), default=False) + if enable: + languages_to_use.append(lang) + print() + log.info("Using languages: %s", languages_to_use) + return languages_to_use + @classmethod def autogenerate( cls, @@ -338,6 +407,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): languages: list[Language] | None = None, save_to_disk: bool = True, interactive: bool = False, + asynchronous: bool = False, ) -> Self: """ Autogenerate a project configuration for a given project root. @@ -349,8 +419,12 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): :param languages: the languages of the project; if None, they will be determined automatically :param save_to_disk: whether to save the project configuration to disk :param interactive: whether to run in interactive CLI mode, asking the user for input where appropriate + :param asynchronous: whether to run in asynchronous mode, where time-consuming configuration parts (currently only the + determination of the list of programming languages) are determined in a background thread and initialised as empty :return: the project configuration """ + if interactive and asynchronous: + raise ValueError("Cannot use interactive mode with asynchronous auto-generation") project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") @@ -358,61 +432,62 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): log.info("Project root: %s", project_root) project_folder_name = project_root.name project_name = project_name or project_folder_name + use_asynchronous_language_determination = False if languages is None: - # determine languages automatically - log.info("Determining programming languages used in the project") - language_composition = determine_programming_language_composition(str(project_root)) - log.info("Language composition: %s", language_composition) - if len(language_composition) == 0: - log.warning( - "No source files for supported language servers were found in %s. " - "Creating project with no configured languages. " - "Symbol-related tools (e.g. find_symbol, get_symbols_overview) will not work " - "when using the LSP backend. You can add languages later via the Serena dashboard " - "or by manually editing the project configuration.", - project_root, - ) - languages_to_use: list[str] = [] + if asynchronous: + use_asynchronous_language_determination = True + languages_to_use = [] # temporarily empty, will be determined in background thread else: - # sort languages by number of files found - languages_and_percentages = sorted( - language_composition.items(), key=lambda item: (item[1], item[0].get_priority()), reverse=True - ) - # find the language with the highest percentage and enable it - top_language_pair = languages_and_percentages[0] - other_language_pairs = languages_and_percentages[1:] - languages_to_use = [top_language_pair[0].value] - # if in interactive mode, ask the user which other languages to enable - if len(other_language_pairs) > 0 and interactive: - print( - "Detected and enabled main language '%s' (%.2f%% of source files)." - % (top_language_pair[0].value, top_language_pair[1]) - ) - print(f"Additionally detected {len(other_language_pairs)} other language(s).\n") - print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.") - print(" Additional language servers use resources and some languages may require additional") - print(" system-level installations/configuration (see Serena documentation).") - print("\nWhich additional languages do you want to enable?") - for lang, perc in other_language_pairs: - enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang.value, perc), default=False) - if enable: - languages_to_use.append(lang.value) - print() - log.info("Using languages: %s", languages_to_use) + determined_languages = cls._determine_project_languages(str(project_root), interactive=interactive) + languages_to_use = [l.value for l in determined_languages] else: languages_to_use = [lang.value for lang in languages] config_with_comments, _ = cls._load_yaml_dict(PROJECT_TEMPLATE_FILE) config_with_comments["project_name"] = project_name config_with_comments["languages"] = languages_to_use + project_yml_path = serena_config.get_project_yml_location(str(project_root)) if save_to_disk: - project_yml_path = serena_config.get_project_yml_location(str(project_root)) log.info("Saving project configuration to %s", project_yml_path) - save_yaml(project_yml_path, config_with_comments) + with cls._save_lock: + save_yaml(project_yml_path, config_with_comments) project_local_yml_path = os.path.join(os.path.dirname(project_yml_path), cls.SERENA_LOCAL_PROJECT_FILE) shutil.copy(PROJECT_LOCAL_TEMPLATE_FILE, project_local_yml_path) - return cls._from_dict(config_with_comments, local_override_keys=[]) + project_config = cls._from_dict(config_with_comments, local_override_keys=[]) + + # if asynchronous language determination is used, start a background thread which updates and saves the configuration + # and set an event which can be awaited to ensure that the configuration is complete + if use_asynchronous_language_determination: + event = threading.Event() + project_config._async_completion_events[id(project_config)] = event + + def async_language_determination(): + try: + with LogTime("Asynchronous language determination", logger=log): + project_config.languages = cls._determine_project_languages(str(project_root), interactive=False) + if save_to_disk: + project_config.save(project_yml_path) + finally: + event.set() + + threading.Thread(target=async_language_determination, name="project-language-determination", daemon=True).start() + + return project_config + + def await_asynchronous_completion(self): + """ + Wait for the asynchronous auto-generation of the configuration to complete (if applicable), ensuring + that, in particular, the list of programming languages is complete, which may be determined asynchronously + when first creating the project configuration. + """ + event = ProjectConfig._async_completion_events.get(id(self)) + if event is None: + return + if not event.is_set(): + log.info("Waiting for asynchronous auto-generation of project configuration to complete ...") + event.wait() + log.info("Asynchronous auto-generation of project configuration completed.") @classmethod def default_project_yml_path(cls, project_root: str | Path) -> str: @@ -590,13 +665,18 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): return os.path.join(os.path.dirname(project_yml_path), cls.SERENA_LOCAL_PROJECT_FILE) @classmethod - def load(cls, project_root: Path | str, serena_config: "SerenaConfig", autogenerate: bool = False) -> Self: + def load( + cls, + project_root: Path | str, + serena_config: "SerenaConfig", + autogen: ProjectConfigAutoGenerationMode = ProjectConfigAutoGenerationMode.NONE, + ) -> Self: """ Load a ProjectConfig instance from the path to the project root. :param project_root: the path to the project root :param serena_config: the global Serena configuration - :param autogenerate: whether to auto-generate the configuration if it does not exist + :param autogen: the auto-generation mode to apply if the project configuration does not yet exist """ project_root = Path(project_root) project_folder_name = project_root.name @@ -605,8 +685,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): # auto-generate if necessary if not os.path.exists(yaml_path): - if autogenerate: - return cls.autogenerate(project_root, serena_config) + if autogen.is_autogen_enabled(): + return cls.autogenerate(project_root, serena_config, asynchronous=autogen == ProjectConfigAutoGenerationMode.ASYNCHRONOUS) else: raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") @@ -653,34 +733,35 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): config_path = project_yml_path log.info("Saving updated project configuration to %s", config_path) - # get the current configuration as a dictionary - cur_dict = self._to_yaml_dict() + with self._save_lock: + # get the current configuration as a dictionary + cur_dict = self._to_yaml_dict() - # load commented map from the original file and update all non-overridden keys - config_with_comments, _ = self._load_yaml_dict(config_path, self.YAML_COMMENT_NORMALISATION) - for key in cur_dict: - if key not in self._local_override_keys: - config_with_comments[key] = cur_dict[key] + # load commented map from the original file and update all non-overridden keys + config_with_comments, _ = self._load_yaml_dict(config_path, self.YAML_COMMENT_NORMALISATION) + for key in cur_dict: + if key not in self._local_override_keys: + config_with_comments[key] = cur_dict[key] - # transfer missing comments from the template file - template_config, _ = self._load_yaml_dict(PROJECT_TEMPLATE_FILE, self.YAML_COMMENT_NORMALISATION) - transfer_yaml_comments(template_config, config_with_comments, self.YAML_COMMENT_NORMALISATION, force_update_all=True) + # transfer missing comments from the template file + template_config, _ = self._load_yaml_dict(PROJECT_TEMPLATE_FILE, self.YAML_COMMENT_NORMALISATION) + transfer_yaml_comments(template_config, config_with_comments, self.YAML_COMMENT_NORMALISATION, force_update_all=True) - # save project.yml - save_yaml(config_path, config_with_comments) + # save project.yml + save_yaml(config_path, config_with_comments) - # update project.local.yml to reflect overridden keys if necessary - if save_project_local_yml: - project_local_yml_path = self._project_local_yml_path(project_yml_path) - if self._local_override_keys and os.path.exists(project_local_yml_path): - log.info("Saving updated local project configuration to %s", project_local_yml_path) - local_config_with_comments, _ = self._load_yaml_dict( - project_local_yml_path, comment_normalisation=YamlCommentNormalisation.NONE, apply_defaults=False - ) - for key in self._local_override_keys: - if key in cur_dict: - local_config_with_comments[key] = cur_dict[key] - save_yaml(project_local_yml_path, local_config_with_comments) + # update project.local.yml to reflect overridden keys if necessary + if save_project_local_yml: + project_local_yml_path = self._project_local_yml_path(project_yml_path) + if self._local_override_keys and os.path.exists(project_local_yml_path): + log.info("Saving updated local project configuration to %s", project_local_yml_path) + local_config_with_comments, _ = self._load_yaml_dict( + project_local_yml_path, comment_normalisation=YamlCommentNormalisation.NONE, apply_defaults=False + ) + for key in self._local_override_keys: + if key in cur_dict: + local_config_with_comments[key] = cur_dict[key] + save_yaml(project_local_yml_path, local_config_with_comments) class RegisteredProject(ToStringMixin): @@ -717,8 +798,21 @@ class RegisteredProject(ToStringMixin): ) @classmethod - def from_project_root(cls, project_root: str | Path, serena_config: "SerenaConfig") -> "RegisteredProject": - project_config = ProjectConfig.load(project_root, serena_config=serena_config) + def from_project_root( + cls, + project_root: str | Path, + serena_config: "SerenaConfig", + autogen: ProjectConfigAutoGenerationMode = ProjectConfigAutoGenerationMode.NONE, + ) -> "RegisteredProject": + """ + Creates a RegisteredProject instance from a project root path, which must exist on disk. + + :param project_root: path to an existing directory + :param serena_config: the Serena configuration + :param autogen: the auto-generation mode to use for the project configuration if it does not yet exist + :return: the RegisteredProject instance + """ + project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogen=autogen) return RegisteredProject( project_root=str(project_root), project_config=project_config, @@ -1085,7 +1179,8 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): def get_registered_project(self, project_root_or_name: str, autoregister: bool = False) -> Optional[RegisteredProject]: """ :param project_root_or_name: path to the project root or the name of the project - :param autoregister: whether to register the project if it exists but is not registered yet + :param autoregister: whether to auto-register projects that are not yet registered in Serena's global configuration + but have an existing project configuration file. Project configuration files are never auto-generated. :return: the registered project, or None if not found """ # look for project by name @@ -1105,7 +1200,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): for project in self.projects: if project.matches_root_path(project_root_or_name): return project - # no registered project found; auto-register if project configuration exists + # no registered project found; optionally auto-register if a project configuration already exists if autoregister: config_path = self.get_project_yml_location(project_root_or_name) if os.path.isfile(config_path): @@ -1129,13 +1224,14 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): self.projects.append(registered_project) self._persist_projects() - def add_project_from_path(self, project_root: Path | str) -> "Project": + def add_project_from_path(self, project_root: Path | str, asynchronous_autogen: bool = False) -> "Project": """ Adds a new project to the Serena configuration from a given path, auto-generating the project with defaults if it does not exist. Will raise a FileExistsError if a project already exists at the path. :param project_root: the path to the project to add + :param asynchronous_autogen: whether to use asynchronous auto-generation for the project configuration :return: the project that was added """ from ..project import Project @@ -1152,7 +1248,8 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'." ) - project_config = ProjectConfig.load(project_root, serena_config=self, autogenerate=True) + autogen = ProjectConfigAutoGenerationMode.ASYNCHRONOUS if asynchronous_autogen else ProjectConfigAutoGenerationMode.SYNCHRONOUS + project_config = ProjectConfig.load(project_root, serena_config=self, autogen=autogen) new_project = Project( project_root=str(project_root), diff --git a/src/serena/project.py b/src/serena/project.py index 87a54adb..51b27ce3 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -11,6 +11,7 @@ from sensai.util.string import TextBuilder, ToStringMixin from serena.config.serena_config import ( LanguageBackend, ProjectConfig, + ProjectConfigAutoGenerationMode, SerenaConfig, ) from serena.ls_manager import LanguageServerFactory, LanguageServerManager @@ -137,13 +138,13 @@ class Project(ToStringMixin): cls, project_root: str | Path, serena_config: "SerenaConfig", - autogenerate: bool = True, + autogen: ProjectConfigAutoGenerationMode = ProjectConfigAutoGenerationMode.SYNCHRONOUS, ) -> "Project": assert serena_config is not None project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") - project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogenerate=autogenerate) + project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogen=autogen) return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config) def save_config(self) -> None: @@ -438,6 +439,10 @@ class Project(ToStringMixin): :return: the language server manager, which is also stored in the project instance """ try: + # ensure that the project configuration, particularly the list of languages is complete, + # despite asynchronous first-time project configuration generation (which may not have completed yet) + self.project_config.await_asynchronous_completion() + # determine timeout to use for LS calls tool_timeout = self.serena_config.tool_timeout if tool_timeout is None or tool_timeout < 0: From 3d3739f6bdb97e362cb27b07696ee8f9ea09c87a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 20:08:58 +0200 Subject: [PATCH 115/170] Split history src/solidlsp/ls.py to src/solidlsp/dependency_provider.py - rename file to target-name --- src/solidlsp/{ls.py => dependency_provider.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/solidlsp/{ls.py => dependency_provider.py} (100%) diff --git a/src/solidlsp/ls.py b/src/solidlsp/dependency_provider.py similarity index 100% rename from src/solidlsp/ls.py rename to src/solidlsp/dependency_provider.py From f200eef4524fd3afb403f40cfcd6e20f3bc8b7e6 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 20:08:58 +0200 Subject: [PATCH 116/170] Split history src/solidlsp/ls.py to src/solidlsp/dependency_provider.py - rename source-file to git-split-temp --- src/solidlsp/ls.py => git-split-temp | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/solidlsp/ls.py => git-split-temp (100%) diff --git a/src/solidlsp/ls.py b/git-split-temp similarity index 100% rename from src/solidlsp/ls.py rename to git-split-temp From cb1a690419f694891394256185e84ae6edd65259 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 20:08:59 +0200 Subject: [PATCH 117/170] Split history src/solidlsp/ls.py to src/solidlsp/dependency_provider.py - restore name of source-file --- git-split-temp => src/solidlsp/ls.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename git-split-temp => src/solidlsp/ls.py (100%) diff --git a/git-split-temp b/src/solidlsp/ls.py similarity index 100% rename from git-split-temp rename to src/solidlsp/ls.py From 58477ffd646cb1a32ef4c4caa150137739e50c78 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 20:11:28 +0200 Subject: [PATCH 118/170] Move dependency providers to separate module --- src/solidlsp/dependency_provider.py | 3123 +-------------------------- src/solidlsp/ls.py | 254 +-- 2 files changed, 17 insertions(+), 3360 deletions(-) diff --git a/src/solidlsp/dependency_provider.py b/src/solidlsp/dependency_provider.py index f296fee6..d3826eba 100644 --- a/src/solidlsp/dependency_provider.py +++ b/src/solidlsp/dependency_provider.py @@ -1,276 +1,13 @@ -import dataclasses -import hashlib -import json import logging import os -import pathlib import shutil -import threading from abc import ABC, abstractmethod -from collections import defaultdict -from collections.abc import Callable, Hashable, Iterator, Sequence -from contextlib import contextmanager -from copy import copy -from dataclasses import dataclass -from pathlib import Path, PurePath -from time import monotonic, perf_counter, sleep -from typing import Any, Self, Union, cast +from collections.abc import Sequence -import pathspec -from sensai.util.pickle import getstate, load_pickle -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.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder -from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig -from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer -from solidlsp.ls_types import UnifiedSymbolInformation -from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils -from solidlsp.lsp_protocol_handler import lsp_types -from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes -from solidlsp.lsp_protocol_handler.lsp_constants import LSPConstants -from solidlsp.lsp_protocol_handler.lsp_types import ( - Definition, - DefinitionParams, - DocumentSymbol, - ImplementationParams, - InitializeParams, - LocationLink, - RenameParams, - SymbolInformation, -) -from solidlsp.lsp_protocol_handler.server import ( - LSPError, - ProcessLaunchInfo, - StringDict, -) from solidlsp.settings import SolidLSPSettings -from solidlsp.util.cache import load_cache, save_cache - -RawDocumentSymbol = Union[DocumentSymbol, SymbolInformation] -""" -Type alias for the raw symbol information returned by a language server in response to a -`textDocument/documentSymbol` request. -The `DocumentSymbol` is the preferred type, but the legacy type `SymbolInformation` is also still used. -""" log = logging.getLogger(__name__) -_debug_enabled = log.isEnabledFor(logging.DEBUG) -"""Serves as a flag that triggers additional computation when debug logging is enabled.""" - - -@dataclasses.dataclass(kw_only=True) -class ReferenceInSymbol: - """A symbol retrieved when requesting reference to a symbol, together with the location of the reference""" - - symbol: ls_types.UnifiedSymbolInformation - line: int - character: int - - -class LSPFileBuffer: - """ - This class is used to store the contents of an open LSP file in memory. - """ - - def __init__( - self, - abs_path: Path, - uri: str, - encoding: str, - version: int, - language_id: str, - ref_count: int, - language_server: "SolidLanguageServer", - open_in_ls: bool = True, - ) -> None: - self.abs_path = abs_path - self.language_server = language_server - self.uri = uri - self._read_file_modified_date: float | None = None - self._contents: str | None = None - self.version = version - self.language_id = language_id - self.ref_count = ref_count - self.encoding = encoding - self._content_hash: str | None = None - self._is_open_in_ls = False - if open_in_ls: - self._open_in_ls() - - def _open_in_ls(self) -> None: - """ - Open the file in the language server if it is not already open. - """ - if self._is_open_in_ls: - return - self._is_open_in_ls = True - self.language_server.server.notify.did_open_text_document( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.URI: self.uri, - LSPConstants.LANGUAGE_ID: self.language_id, - LSPConstants.VERSION: 0, - LSPConstants.TEXT: self.contents, - } - } - ) - - def close(self) -> None: - if self._is_open_in_ls: - self.language_server.server.notify.did_close_text_document( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.URI: self.uri, - } - } - ) - - def ensure_open_in_ls(self) -> None: - """Ensure that the file is opened in the language server.""" - self._open_in_ls() - - @property - def contents(self) -> str: - file_modified_date = self.abs_path.stat().st_mtime - - # if contents are cached, check if they are stale (file modification since last read) and invalidate if so - if self._contents is not None: - assert self._read_file_modified_date is not None - if file_modified_date > self._read_file_modified_date: - self._contents = None - - if self._contents is None: - self._read_file_modified_date = file_modified_date - self._contents = FileUtils.read_file(str(self.abs_path), self.encoding) - self._content_hash = None - - return self._contents - - @contents.setter - def contents(self, new_contents: str) -> None: - """ - Sets new contents for the file buffer (in-memory change only). - Persistence of the change to disk must be handled separately. - - :param new_contents: the new contents to set - """ - self._contents = new_contents - self._content_hash = None - - @property - def content_hash(self) -> str: - if self._content_hash is None: - self._content_hash = hashlib.md5(self.contents.encode(self.encoding)).hexdigest() - return self._content_hash - - def split_lines(self) -> list[str]: - """Splits the contents of the file into lines.""" - return self.contents.split("\n") - - -class SymbolBody(ToStringMixin): - """ - Representation of the body of a symbol, which allows the extraction of the symbol's text - from the lines of the file it is defined in. - - Instances that share the same lines buffer are memory-efficient, - using only 4 integers and a reference to the lines buffer from which the text can be extracted, - i.e. a core representation of only about 40 bytes per body. - """ - - def __init__(self, lines: list[str], start_line: int, start_col: int, end_line: int, end_col: int) -> None: - self._lines = lines - self._start_line = start_line - self._start_col = start_col - self._end_line = end_line - self._end_col = end_col - - def _tostring_excludes(self) -> list[str]: - return ["_lines"] - - def get_text(self) -> str: - # extract relevant lines - symbol_body = "\n".join(self._lines[self._start_line : self._end_line + 1]) - - # remove leading content from the first line - symbol_body = symbol_body[self._start_col :] - - # remove trailing content from the last line - last_line = self._lines[self._end_line] - trailing_length = len(last_line) - self._end_col - if trailing_length > 0: - symbol_body = symbol_body[: -(len(last_line) - self._end_col)] - - return symbol_body - - -class SymbolBodyFactory: - """ - A factory for the creation of SymbolBody instances from symbols dictionaries. - Instances created from the same factory instance are memory-efficient, as they share - the same lines buffer. - """ - - def __init__(self, file_buffer: LSPFileBuffer): - self._lines = file_buffer.split_lines() - - def create_symbol_body(self, symbol: UnifiedSymbolInformation) -> SymbolBody: - existing_body = symbol.get("body", None) - if existing_body and isinstance(existing_body, SymbolBody): - return existing_body - - assert "location" in symbol - start_line = symbol["location"]["range"]["start"]["line"] - end_line = symbol["location"]["range"]["end"]["line"] - start_col = symbol["location"]["range"]["start"]["character"] - end_col = symbol["location"]["range"]["end"]["character"] - return SymbolBody(self._lines, start_line, start_col, end_line, end_col) - - -class DocumentSymbols: - # IMPORTANT: Instances of this class are persisted in the high-level document symbol cache - - def __init__(self, root_symbols: list[ls_types.UnifiedSymbolInformation]): - self.root_symbols = root_symbols - self._all_symbols: list[ls_types.UnifiedSymbolInformation] | None = None - - def __getstate__(self) -> dict: - return getstate(DocumentSymbols, self, transient_properties=["_all_symbols"]) - - def iter_symbols(self) -> Iterator[ls_types.UnifiedSymbolInformation]: - """ - Iterate over all symbols in the document symbol tree. - Yields symbols in a depth-first manner. - """ - if self._all_symbols is not None: - yield from self._all_symbols - return - - def traverse(s: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]: - yield s - for child in s.get("children", []): - yield from traverse(child) - - for root_symbol in self.root_symbols: - yield from traverse(root_symbol) - - def get_all_symbols_and_roots(self) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]: - """ - This function returns all symbols in the document as a flat list and the root symbols. - It exists to facilitate migration from previous versions, where this was the return interface of - the LS method that obtained document symbols. - - :return: A tuple containing a list of all symbols in the document and a list of root symbols. - """ - if self._all_symbols is None: - self._all_symbols = list(self.iter_symbols()) - return self._all_symbols, self.root_symbols - class LanguageServerDependencyProvider(ABC): """ @@ -507,2861 +244,3 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCo def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: return base_command + list(self._extra_args) - - -class SolidLanguageServer(ABC): - """ - High-level abstraction for language server interaction, which wraps the underlying low-level LSP interface - """ - - CACHE_FOLDER_NAME = "cache" - RAW_DOCUMENT_SYMBOLS_CACHE_VERSION = 1 - """ - global version identifier for raw symbol caches; an LS-specific version is defined separately and combined with this. - This should be incremented whenever there is a change in the way raw document symbols are stored. - If the result of a language server changes in a way that affects the raw document symbols, - the LS-specific version should be incremented instead. - """ - RAW_DOCUMENT_SYMBOL_CACHE_FILENAME = "raw_document_symbols.pkl" - RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK = "document_symbols_cache_v23-06-25.pkl" - DOCUMENT_SYMBOL_CACHE_VERSION = 4 - DOCUMENT_SYMBOL_CACHE_FILENAME = "document_symbols.pkl" - - # Directories that should always be ignored regardless of language: - # VCS internals, virtual environments, caches, and serena's own data. - _ALWAYS_IGNORED_DIRS = frozenset( - { - ".git", - ".svn", - ".hg", - ".bzr", # VCS - ".venv", - ".env", # virtual environments - ".cache", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", # caches - ".tox", - ".nox", # test runners - ".idea", # IDE internals - ".serena", # serena's own data - ".vscode", # Doesn't contain symbols - } - ) - - # To be overridden and extended by subclasses - def is_ignored_dirname(self, dirname: str) -> bool: - """ - A language-specific condition for directories that should always be ignored. For example, venv - in Python and node_modules in JS/TS should be ignored always. - """ - return dirname in self._ALWAYS_IGNORED_DIRS - - @staticmethod - def _determine_log_level(line: str) -> int: - """ - Classify a stderr line from the language server to determine appropriate logging level. - - Language servers may emit informational messages to stderr that contain words like "error" - but are not actual errors. Subclasses can override this method to filter out known - false-positive patterns specific to their language server. - - :param line: The stderr line to classify - :return: A logging level (logging.DEBUG, logging.INFO, logging.WARNING, or logging.ERROR) - """ - line_lower = line.lower() - - # Default classification: treat lines with "error" or "exception" as ERROR level - if "error" in line_lower or "exception" in line_lower or line.startswith("E["): - return logging.ERROR - else: - return logging.INFO - - @classmethod - def get_language_enum_instance(cls) -> Language: - return Language.from_ls_class(cls) - - @classmethod - def supports_implementation_request(cls) -> bool: - """ - Return whether this language server supports ``textDocument/implementation``. - """ - return False - - @classmethod - def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str: - """ - Returns the directory where the language server resources are downloaded. - This is used to store language server binaries, configuration files, etc. - """ - result = os.path.join(solidlsp_settings.ls_resources_dir, cls.__name__) - - # Migration of previously downloaded LS resources that were downloaded to a subdir of solidlsp instead of to the user's home - pre_migration_ls_resources_dir = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__) - if os.path.exists(pre_migration_ls_resources_dir): - if os.path.exists(result): - # if the directory already exists, we just remove the old resources - shutil.rmtree(result, ignore_errors=True) - else: - # move old resources to the new location - shutil.move(pre_migration_ls_resources_dir, result) - if mkdir: - os.makedirs(result, exist_ok=True) - return result - - @classmethod - def create( - cls, - config: LanguageServerConfig, - repository_root_path: str, - timeout: float | None = None, - solidlsp_settings: SolidLSPSettings | None = None, - ) -> "SolidLanguageServer": - """ - Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. - - If language is Java, then ensure that jdk-17.0.6 or higher is installed, `java` is in PATH, and JAVA_HOME is set to the installation directory. - If language is JS/TS, then ensure that node (v18.16.0 or higher) is installed and in PATH. - - :param repository_root_path: The root path of the repository. - :param config: language server configuration. - :param logger: The logger to use. - :param timeout: the timeout for requests to the language server. If None, no timeout will be used. - :param solidlsp_settings: additional settings - :return LanguageServer: A language specific LanguageServer instance. - """ - ls: SolidLanguageServer - if solidlsp_settings is None: - solidlsp_settings = SolidLSPSettings() - - # Ensure repository_root_path is absolute to avoid issues with file URIs - repository_root_path = os.path.abspath(repository_root_path) - - ls_class = config.code_language.get_ls_class() - # All language server implementations are required to use the same signature of the constructor - # (which differs from the signature of the base class constructor). - ls = ls_class(config, repository_root_path, solidlsp_settings) - ls.set_request_timeout(timeout) - return ls - - def __init__( - self, - config: LanguageServerConfig, - repository_root_path: str, - process_launch_info: ProcessLaunchInfo | None, - language_id: str, - solidlsp_settings: SolidLSPSettings, - cache_version_raw_document_symbols: Hashable = 1, - ): - """ - Initializes a LanguageServer instance. - - Do not instantiate this class directly. Use `LanguageServer.create` method instead. - - :param config: the language server's configuration. - :param repository_root_path: the root path of the repository. - :param process_launch_info: (DEPRECATED: pass None and implement _create_dependency_provider instead) - the command used to start the actual language server. - The command must pass appropriate flags to the binary, so that it runs in the stdio mode, - as opposed to HTTP, TCP modes supported by some language servers. - :param language_id: The language identifier which will be passed to the language server in the `textDocument/didOpen` - notification by default. - If the language server uses multiple language identifiers, it must override the method `get_language_id_for_file` - to provide the appropriate identifier for each type of file. - :param solidlsp_settings: the global SolidLSP settings - :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming - from this specific language server. This should be incremented by subclasses calling this constructor - whenever the format of the raw document symbols changes (typically because the language server - improves/fixes its output). - """ - self.config = config - self._solidlsp_settings = solidlsp_settings - lang = self.get_language_enum_instance() - self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) - """ - the (user-provided) language server-specific settings - """ - self._ls_resources_dir = self.ls_resources_dir(solidlsp_settings) - log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}") - self._encoding = config.encoding - self.repository_root_path: str = repository_root_path - - log.debug( - f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}" - ) - - self.language_id = language_id - """ - default language identifier to be passed to the language server in `textDocument/didOpen` notifications. - """ - self.open_file_buffers: dict[str, LSPFileBuffer] = {} - self.language = self.get_language_enum_instance() - """ - identifies the language server (not to be confused with the language id passed to the language server) - """ - # The source filename matcher is a @cache'd per-language singleton. A previous project may - # have extended it (e.g. Perl's file_filter adding .cgi); reset it here so every activation - # starts from the language's default extensions, then language-server subclasses re-apply - # their own settings during the rest of __init__. - self.language.get_source_fn_matcher().reset() - self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {} - self._published_diagnostics_generation_by_uri: dict[str, int] = {} - self._published_diagnostics_generation = 0 - self._published_diagnostics_condition = threading.Condition() - - # initialise symbol caches - self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id - self.cache_dir.mkdir(parents=True, exist_ok=True) - # * raw document symbols cache - self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols - self._raw_document_symbols_cache: dict[str, tuple[str, list[DocumentSymbol] | list[SymbolInformation] | None]] = {} - """maps relative file paths to a tuple of (file_content_hash, raw_root_symbols)""" - self._raw_document_symbols_cache_is_modified: bool = False - self._load_raw_document_symbols_cache() - # * high-level document symbols cache - self._document_symbols_cache: dict[str, tuple[str, DocumentSymbols]] = {} - """maps relative file paths to a tuple of (file_content_hash, document_symbols)""" - self._document_symbols_cache_is_modified: bool = False - self._load_document_symbols_cache() - - self.server_started = False - if config.trace_lsp_communication: - - def logging_fn(source: str, target: str, msg: StringDict | str) -> None: - log.debug(f"LSP: {source} -> {target}: {msg!s}") - - else: - logging_fn = None - - # create the LanguageServerHandler, which provides the functionality to start the language server and communicate with it, - # preparing the launch command beforehand - self._dependency_provider: LanguageServerDependencyProvider | None = None - if process_launch_info is None: - self._dependency_provider = self._create_dependency_provider() - process_launch_info = self._create_process_launch_info() - log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}") - self.server = self._create_language_server_interface(process_launch_info, logging_fn) - """ - the low-level language server interface - """ - self.server.on_any_notification(self._observe_server_notification) - - # Set up the pathspec matcher for the ignored paths - # for all absolute paths in ignored_paths, convert them to relative paths - processed_patterns = [] - for pattern in set(config.ignored_paths): - # Normalize separators (pathspec expects forward slashes) - pattern = pattern.replace(os.path.sep, "/") - processed_patterns.append(pattern) - log.debug(f"Processing {len(processed_patterns)} ignored paths from the config") - - # Create a pathspec matcher from the processed patterns - self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) - - self._has_waited_for_cross_file_references = False - - self._abs_workspace_folders_indexed = self.config.get_absolute_workspace_folders(self.repository_root_path) - self._abs_workspace_folders_additional = self.config.get_absolute_additional_workspace_folders(self.repository_root_path) - """ - additional workspace folders, which are passed to the language server in the initialization request - but which are not indexed by SolidLSP, i.e. not traversed for full symbol tree - """ - self._abs_workspace_folders_all = self._abs_workspace_folders_indexed + self._abs_workspace_folders_additional - - @property - def custom_settings(self) -> SolidLSPSettings.CustomLSSettings: - """ - The (user-provided) language server-specific settings. - """ - return self._custom_settings - - def _create_dependency_provider(self) -> LanguageServerDependencyProvider: - """ - Creates the dependency provider for this language server. - - Subclasses should override this method to provide their specific dependency provider. - This method is only called if process_launch_info is not passed to __init__. - """ - raise NotImplementedError( - f"{self.__class__.__name__} must implement _create_dependency_provider() or pass process_launch_info to __init__()" - ) - - def _create_process_launch_info(self) -> ProcessLaunchInfo: - assert self._dependency_provider is not None - cmd = self._dependency_provider.create_launch_command() - env = self._dependency_provider.create_launch_command_env() - return ProcessLaunchInfo(cmd=cmd, cwd=self.repository_root_path, env=env) - - def _create_language_server_interface( - self, process_launch_info: ProcessLaunchInfo, logging_fn: Callable[[str, str, StringDict | str], None] | None - ) -> LanguageServerInterface: - """ - Creates the low-level language server interface for LSP communication. - - The interface is created but not started. - - The default implementation creates a StdioLanguageServer, but subclasses can override this method to create a different type - of interface if needed. - - :param process_launch_info: process launch information - :param logging_fn: the trace logging function - :return: the interface - """ - return StdioLanguageServer( - process_launch_info, - language=self.language, - determine_log_level=self._determine_log_level, - logger=logging_fn, - start_independent_lsp_process=self.config.start_independent_lsp_process, - ) - - # --- diagnostics-related functions --- - - def _observe_server_notification(self, method: str, params: Any) -> None: - """ - Observe notifications sent by the language server. - - This is used for generic cross-language bookkeeping that must work independently of - language-specific notification handlers. - """ - if method == "textDocument/publishDiagnostics": - self._store_published_diagnostics(params) - - def _store_published_diagnostics(self, params: Any) -> None: - """ - Store diagnostics received through ``textDocument/publishDiagnostics``. - """ - if not isinstance(params, dict): - return - - uri = params.get("uri") - diagnostics = params.get("diagnostics") - if not isinstance(uri, str) or not isinstance(diagnostics, list): - return - - normalized_diagnostics: list[ls_types.Diagnostic] = [] - for diagnostic in diagnostics: - if not isinstance(diagnostic, dict): - continue - if "message" not in diagnostic or "range" not in diagnostic: - continue - - normalized_diagnostic: ls_types.Diagnostic = { - "uri": uri, - "message": diagnostic["message"], - "range": diagnostic["range"], - } - severity = diagnostic.get("severity") - if isinstance(severity, int): - normalized_diagnostic["severity"] = ls_types.DiagnosticSeverity(severity) - - code = diagnostic.get("code") - if isinstance(code, int | str): - normalized_diagnostic["code"] = code - - if "source" in diagnostic: - normalized_diagnostic["source"] = diagnostic["source"] - normalized_diagnostics.append(ls_types.Diagnostic(**normalized_diagnostic)) - - # canonicalize the key so lookups using URIs produced by pathlib.Path.as_uri() match - # what servers publish (e.g. file:///c%3A/... or file:///c:/... vs. file:///C:/...) - key = self._canonicalize_published_diagnostics_uri(uri) - - with self._published_diagnostics_condition: - self._published_diagnostics_generation += 1 - self._published_diagnostics[key] = normalized_diagnostics - self._published_diagnostics_generation_by_uri[key] = self._published_diagnostics_generation - self._published_diagnostics_condition.notify_all() - - @staticmethod - def _canonicalize_published_diagnostics_uri(uri: str) -> str: - """ - Canonicalizes a ``file://`` URI so that diagnostics published by language servers - and lookups based on ``pathlib.Path.as_uri()`` agree on the same key. - - On Windows, servers may publish under ``file:///c%3A/...`` or ``file:///c:/...`` while - ``pathlib.Path.as_uri()`` produces ``file:///C:/...``. The canonical form uses an - upper-case drive letter and a plain colon. - """ - if os.name != "nt" or not uri.startswith("file:///"): - return uri - - # extract the segment after "file:///" up to the next slash and look for a drive letter - prefix = "file:///" - rest = uri[len(prefix) :] - slash = rest.find("/") - head = rest if slash < 0 else rest[:slash] - tail = "" if slash < 0 else rest[slash:] - - if (len(head) >= 2 and head[0].isalpha() and head[1] == ":") or ( - len(head) >= 4 and head[0].isalpha() and head[1:4].lower() == "%3a" - ): - head = head[0].upper() + ":" - else: - return uri - - return prefix + head + tail - - def _get_published_diagnostics_generation(self, uri: str) -> int: - key = self._canonicalize_published_diagnostics_uri(uri) - with self._published_diagnostics_condition: - return self._published_diagnostics_generation_by_uri.get(key, -1) - - def _wait_for_published_diagnostics( - self, - uri: str, - after_generation: int, - timeout: float, - ) -> list[ls_types.Diagnostic] | None: - key = self._canonicalize_published_diagnostics_uri(uri) - deadline = perf_counter() + timeout - with self._published_diagnostics_condition: - while True: - current_generation = self._published_diagnostics_generation_by_uri.get(key, -1) - if current_generation > after_generation: - return list(self._published_diagnostics.get(key, [])) - - remaining_timeout = deadline - perf_counter() - if remaining_timeout <= 0: - return None - self._published_diagnostics_condition.wait(timeout=remaining_timeout) - - def _get_cached_published_diagnostics(self, uri: str) -> list[ls_types.Diagnostic] | None: - key = self._canonicalize_published_diagnostics_uri(uri) - with self._published_diagnostics_condition: - diagnostics = self._published_diagnostics.get(key) - if diagnostics is None: - return None - return list(diagnostics) - - @staticmethod - def _diagnostic_matches_range(diagnostic: ls_types.Diagnostic, start_line: int, end_line: int) -> bool: - diagnostic_start_line = diagnostic["range"]["start"]["line"] - diagnostic_end_line = diagnostic["range"]["end"]["line"] - - # normalize inverted ranges: some servers (e.g. Julia's JuliaSyntax.jl for unterminated - # expressions) emit diagnostics whose end position lies before the start position. - # treat such ranges as a single span covering both lines. - lo = min(diagnostic_start_line, diagnostic_end_line) - hi = max(diagnostic_start_line, diagnostic_end_line) - - # when end_line < 0 the caller imposes no upper bound; only enforce the lower bound. - if end_line < 0: - return hi >= start_line - return lo <= end_line and hi >= start_line - - @staticmethod - def _diagnostic_matches_min_severity(diagnostic: ls_types.Diagnostic, min_severity: int) -> bool: - severity = diagnostic.get("severity") - if severity is None: - return True - return int(severity) <= min_severity - - @classmethod - def _filter_diagnostics( - cls, - diagnostics: list[ls_types.Diagnostic], - start_line: int, - end_line: int, - min_severity: int, - ) -> list[ls_types.Diagnostic]: - diagnostics = [d for d in diagnostics if cls._diagnostic_matches_range(d, start_line, end_line)] - diagnostics = [d for d in diagnostics if cls._diagnostic_matches_min_severity(d, min_severity)] - return diagnostics - - def _validate_text_document_diagnostics_request( - self, - relative_file_path: str, - start_line: int, - end_line: int, - min_severity: int, - ) -> str: - if not self.server_started: - log.error("request_text_document_diagnostics called before Language Server started") - raise SolidLSPException("Language Server not started") - if start_line < 0: - raise ValueError(f"start_line must be non-negative, got {start_line}") - if end_line != -1 and end_line < start_line: - raise ValueError(f"end_line must be -1 or >= start_line, got {end_line} < {start_line}") - if min_severity not in {1, 2, 3, 4}: - raise ValueError(f"min_severity must be one of 1, 2, 3, 4, got {min_severity}") - return pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() - - def get_published_diagnostics_generation(self, relative_file_path: str) -> int: - """ - Get the generation number for the latest published diagnostics of a file. - - :param relative_file_path: The relative path of the file. - :return: the generation number, or ``-1`` if none were published yet. - """ - uri = pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() - return self._get_published_diagnostics_generation(uri) - - def get_cached_published_text_document_diagnostics( - self, - relative_file_path: str, - start_line: int = 0, - end_line: int = -1, - min_severity: int = 4, - ) -> list[ls_types.Diagnostic] | None: - """ - Get cached diagnostics received through ``textDocument/publishDiagnostics``. - - :param relative_file_path: The relative path of the file to retrieve diagnostics for. - :param start_line: the first 0-based line to include in the result. - :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. - :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. - Diagnostics with lower-or-equal numeric severity are returned. - :return: the cached diagnostics, or ``None`` if no diagnostics were published yet. - """ - uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) - diagnostics = self._get_cached_published_diagnostics(uri) - if diagnostics is None: - return None - return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) - - def request_published_text_document_diagnostics( - self, - relative_file_path: str, - after_generation: int = -1, - timeout: float = 2.5, - start_line: int = 0, - end_line: int = -1, - min_severity: int = 4, - allow_cached: bool = True, - ) -> list[ls_types.Diagnostic] | None: - """ - Wait for diagnostics received through ``textDocument/publishDiagnostics`` and return them. - - :param relative_file_path: The relative path of the file to retrieve diagnostics for. - :param after_generation: only return diagnostics published after this generation. ``-1`` accepts the next publication. - :param timeout: the maximum time to wait for a newer publication. - :param start_line: the first 0-based line to include in the result. - :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. - :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. - Diagnostics with lower-or-equal numeric severity are returned. - :param allow_cached: whether to fall back to the current cached diagnostics if no newer publication arrives in time. - :return: the published diagnostics, or ``None`` if no diagnostics are available. - """ - uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) - published_uri = self._get_published_diagnostics_uri(uri) - diagnostics: list[ls_types.Diagnostic] | None = None - - # keeping the document open - with self.open_file(relative_file_path): - diagnostics = self._wait_for_relevant_published_diagnostics( - uri=published_uri, - after_generation=after_generation, - timeout=timeout, - allow_cached=allow_cached, - ) - - if diagnostics is None: - return None - - return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) - - def _get_published_diagnostics_uri(self, request_uri: str) -> str: - """ - Gets the URI under which published diagnostics should be looked up. - """ - return request_uri - - def _supports_pull_diagnostics(self) -> bool: - """ - Whether the language server handles ``textDocument/diagnostic`` (LSP 3.17 pull - diagnostics) gracefully. Subclasses should override and return ``False`` when the - underlying server does not implement pull diagnostics, especially when sending - the request would terminate the server (e.g. LanguageServer.jl raises an error - for unknown methods that crashes the process). - """ - return True - - def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: - """ - Gets the timeout for waiting on published diagnostics after a diagnostics request. - """ - return 2.5 - - def _accept_published_diagnostics(self, diagnostics: list[ls_types.Diagnostic]) -> bool: - """ - Determines whether a published diagnostics payload should satisfy the current wait. - """ - return bool(diagnostics) - - def _wait_for_relevant_published_diagnostics( - self, - uri: str, - after_generation: int, - timeout: float, - allow_cached: bool = True, - ) -> list[ls_types.Diagnostic] | None: - """ - Waits for a published diagnostics payload that is relevant for the current request. - """ - deadline = monotonic() + timeout - current_after_generation = after_generation - - while True: - remaining_timeout = deadline - monotonic() - if remaining_timeout <= 0: - break - - diagnostics = self._wait_for_published_diagnostics( - uri=uri, - after_generation=current_after_generation, - timeout=remaining_timeout, - ) - if diagnostics is None: - break - if self._accept_published_diagnostics(diagnostics): - return diagnostics - current_after_generation = self._get_published_diagnostics_generation(uri) - - if allow_cached: - diagnostics = self._get_cached_published_diagnostics(uri) - if diagnostics is not None and self._accept_published_diagnostics(diagnostics): - return diagnostics - - return None - - def request_text_document_diagnostics( - self, - relative_file_path: str, - start_line: int = 0, - end_line: int = -1, - min_severity: int = 4, - ) -> list[ls_types.Diagnostic]: - """ - Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server - to find diagnostics for the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file to retrieve diagnostics for - :param start_line: the first 0-based line to include in the result. - :param end_line: the last 0-based line to include in the result. `-1` means no upper bound. - :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. - Diagnostics with lower-or-equal numeric severity are returned. - - :return: A list of diagnostics for the file - """ - uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) - published_uri = self._get_published_diagnostics_uri(uri) - diagnostics_before_request = self._get_published_diagnostics_generation(published_uri) - ret: list[ls_types.Diagnostic] | None = None - pull_diagnostics_failed = False - - with self.open_file(relative_file_path): - response: Any = None - # only send pull diagnostics when the server actually supports it; some servers - # (e.g. Julia's LanguageServer.jl) hard-error and crash the process on unknown methods - if self._supports_pull_diagnostics(): - try: - response = self.server.send.text_document_diagnostic( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.URI: uri, - } - } - ) - except SolidLSPException as ex: - log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex) - response = None - pull_diagnostics_failed = True - - if response is not None: - assert isinstance(response, dict), ( - f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" - ) - ret = [] - for item in response["items"]: # type: ignore - new_item: ls_types.Diagnostic = { - "uri": uri, - "severity": item["severity"], - "message": item["message"], - "range": item["range"], - "code": item.get("code"), # type: ignore - } - if "source" in item: - new_item["source"] = item["source"] - ret.append(ls_types.Diagnostic(**new_item)) - - if not ret: - published_diagnostics = self._wait_for_relevant_published_diagnostics( - uri=published_uri, - after_generation=diagnostics_before_request, - timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed), - ) - if published_diagnostics is not None: - ret = published_diagnostics - - if ret is None: - return [] - - return self._filter_diagnostics(ret, start_line, end_line, min_severity) - - def _get_wait_time_for_cross_file_referencing(self) -> float: - """Meant to be overridden by subclasses for LS that don't have a reliable "finished initializing" signal. - - LS may return incomplete results on calls to `request_references` (only references found in the same file), - if the LS is not fully initialized yet. - """ - return 2 - - # --- Cross-workspace / additional workspace folder support --- - - @staticmethod - def _path_contains_dots(relative_file_path: str) -> bool: - """Check if a relative path traverses outside the workspace root via '..' components.""" - return ".." in PurePath(relative_file_path).parts - - @dataclass - class PathWorkspaceStatus: - """ - Represents the status of a path with respect to the language server's workspace folders. - """ - - ls: "SolidLanguageServer" - resolve_abs_path: Path - is_in_workspace_folder: bool - workspace_root: str | None = None - """ - the workspace root the path is within/relative to, if any - """ - - @classmethod - def from_abs_resolved_path(cls, path: Path, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": - """ - :param path: an absolute, resolved Path instance - :param ls: the language server instance - """ - for workspace in ls._abs_workspace_folders_all: - if path.is_relative_to(workspace): - return SolidLanguageServer.PathWorkspaceStatus( - ls=ls, is_in_workspace_folder=True, workspace_root=workspace, resolve_abs_path=path - ) - return SolidLanguageServer.PathWorkspaceStatus(ls=ls, is_in_workspace_folder=False, resolve_abs_path=path) - - @classmethod - def from_relative_path(cls, relative_path: str, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": - """ - :param relative_path: a relative path from the repository root - :param ls: the language server instance - """ - return cls.from_abs_resolved_path(pathlib.Path(ls.repository_root_path, relative_path).resolve(), ls) - - def check_within_workspace_or_raise(self): - if not self.is_in_workspace_folder: - raise ValueError( - f"Path {self.resolve_abs_path} is outside of configured workspaces. " - f"Configured workspaces: {self.ls._abs_workspace_folders_all}." - ) - - def _resolve_file_uri(self, relative_file_path: str) -> str: - """Construct a canonical file URI from a relative path. - - For cross-workspace paths containing '..', the path is resolved to - produce a clean URI without '..' segments. - """ - p = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)) - if self._path_contains_dots(relative_file_path): - p = p.resolve() - self.PathWorkspaceStatus.from_abs_resolved_path(p, self).check_within_workspace_or_raise() - return p.as_uri() - - def _activate_additional_workspaces(self) -> None: - """Open a representative file from each additional workspace folder to - trigger project loading in the language server. - - Many language servers only load projects on-demand when files are opened. - This method finds a source file in each additional workspace folder via - :meth:`_find_representative_source_file` and opens it, keeping it open - for the lifetime of this language server instance. - - Subclasses must override :meth:`_find_representative_source_file` to - enable this feature; the default implementation raises ``NotImplementedError``. - """ - opened_count = 0 - for additional_workspace in self._abs_workspace_folders_additional: - source_file = self._find_representative_source_file(additional_workspace) - if source_file is None: - log.warning("No source file found in additional workspace folder: %s", additional_workspace) - continue - - rel_path = os.path.relpath(source_file, self.repository_root_path) - log.info("Opening %s to trigger project loading for %s", rel_path, os.path.basename(additional_workspace)) - - abs_path = pathlib.Path(source_file).resolve() - uri = abs_path.as_uri() - language_id = self._get_language_id_for_file(rel_path) - - self._signal_expect_indexing() - fb = LSPFileBuffer( - abs_path=abs_path, - uri=uri, - encoding=self._encoding, - version=0, - language_id=language_id, - ref_count=1, - language_server=self, - open_in_ls=True, - ) - self.open_file_buffers[uri] = fb - opened_count += 1 - - if opened_count > 0: - log.info("Waiting for %d additional workspace(s) to index...", opened_count) - self._wait_for_additional_workspace_indexing() - - def _find_representative_source_file(self, directory: str) -> str | None: - """Find a source file suitable for triggering project loading in the given directory. - - Must be overridden by subclasses that support ``additional_workspace_folders``. - Return ``None`` if no suitable file is found. - - :param directory: Absolute path to the workspace folder. - :return: Absolute path to a source file, or None. - """ - raise NotImplementedError( - f"{type(self).__name__} does not yet support additional_workspace_folders. " - f"Override _find_representative_source_file() to enable this feature." - ) - - def _signal_expect_indexing(self) -> None: - """Signal that new files are about to be opened and async indexing should be awaited. - - Override in subclasses that track indexing progress (e.g. via $/progress). - Default implementation is a no-op. - """ - - def _wait_for_additional_workspace_indexing(self) -> None: - """Wait for additional workspace indexing to complete. - - Override in subclasses that track indexing progress. - Default implementation is a no-op. - """ - - def set_request_timeout(self, timeout: float | None) -> None: - """ - :param timeout: the timeout, in seconds, for requests to the language server. - """ - self.server.set_request_timeout(timeout) - - def get_ignore_spec(self) -> pathspec.PathSpec: - """ - Returns the pathspec matcher for the paths that were configured to be ignored through - the language server configuration. - - This is a subset of the full language-specific ignore spec that determines - which files are relevant for the language server. - - This matcher is useful for operations outside of the language server, - such as when searching for relevant non-language files in the project. - """ - return self._ignore_spec - - def get_source_fn_matcher(self) -> FilenameMatcher: - """ - :return: the source filename matcher for this language server, which must positively match all files that - are understood by this language server or are discovered as containing sources indirectly, e.g. via references - """ - # By default, use the matcher of the language - return self.language.get_source_fn_matcher() - - def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: - """ - Determine if a path should be ignored based on file type - and ignore patterns. - - Missing paths are classified without raising, since language servers may report - locations of generated files that are not present on disk (e.g. compiled classes - under build output). A missing path is treated as a file if its name has a suffix - and as a directory otherwise; directory-only ignore patterns (e.g. ``build/``) - match only existing directories. - - :param relative_path: Relative path to check - :param ignore_unsupported_files: whether files that are not supported source files should be ignored - - :return: True if the path should be ignored, False otherwise - """ - abs_path = os.path.join(self.repository_root_path, relative_path) - rel_path = Path(relative_path) - - # determine whether the path is a file: from the filesystem if the path exists, lexically otherwise - exists = os.path.exists(abs_path) - if not exists: - log.debug("Path %s does not exist; the ignore check is performed on the path itself", abs_path) - is_file = os.path.isfile(abs_path) if exists else bool(rel_path.suffix) - - # check each directory component against always-ignored dirnames - dir_parts = rel_path.parts - if is_file: - dir_parts = dir_parts[:-1] - for part in dir_parts: - if not part: # Skip empty parts (e.g., from leading '/') - continue - if self.is_ignored_dirname(part): - return True - - # apply the unsupported-extension rule to files - if is_file and ignore_unsupported_files: - fn_matcher = self.get_source_fn_matcher() - if not fn_matcher.is_relevant_filename(abs_path): - return True - - return match_path(relative_path, self.get_ignore_spec(), root_path=self.repository_root_path) - - @contextmanager - def start_server_context(self) -> Iterator["SolidLanguageServer"]: - self.start() - try: - yield self - finally: - self.stop() - - @abstractmethod - def _start_server(self) -> None: - """ - Starts the low-level language server interface (i.e. self.server). - - This method must ultimately call `self.server.start()` and may perform additional setup before or after starting the server, - such as waiting for initialization to complete or activating additional workspaces. - """ - - def _get_language_id_for_file(self, relative_file_path: str) -> str: - """ - Determines the language identifier to pass to the language server for the given file, - particularly `textDocument/didOpen` requests. - - Override this method in subclasses to return file-specific language identifiers. - The default implementation returns the main identifier passed at construction (self.language_id). - """ - return self.language_id - - @contextmanager - def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterator[LSPFileBuffer]: - """ - Open a file in the Language Server. This is required before making any requests to the Language Server. - - :param relative_file_path: The relative path of the file to open. - :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. - Set this to False to read the local file buffer without notifying the LS; the file can - be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. - """ - if not self.server_started: - log.error("open_file called before Language Server started") - raise SolidLSPException("Language Server not started") - - absolute_file_path = Path(self.repository_root_path, relative_file_path) - if self._path_contains_dots(relative_file_path): - absolute_file_path = absolute_file_path.resolve() - uri = absolute_file_path.as_uri() - - if uri in self.open_file_buffers: - fb = self.open_file_buffers[uri] - assert fb.uri == uri - assert fb.ref_count >= 1 - - fb.ref_count += 1 - if open_in_ls: - fb.ensure_open_in_ls() - else: - version = 0 - language_id = self._get_language_id_for_file(relative_file_path) - fb = LSPFileBuffer( - abs_path=absolute_file_path, - uri=uri, - encoding=self._encoding, - version=version, - language_id=language_id, - ref_count=1, - language_server=self, - open_in_ls=open_in_ls, - ) - self.open_file_buffers[uri] = fb - - try: - yield fb - finally: - fb.ref_count -= 1 - if fb.ref_count == 0: - fb.close() - del self.open_file_buffers[uri] - - @contextmanager - def _open_file_context( - self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None, open_in_ls: bool = True - ) -> Iterator[LSPFileBuffer]: - """ - Internal context manager to open a file, optionally reusing an existing file buffer. - - :param relative_file_path: the relative path of the file to open. - :param file_buffer: an optional existing file buffer to reuse. - :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. - Set this to False to read the local file buffer without notifying the LS; the file can - be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. - """ - if file_buffer is not None: - expected_uri = self._resolve_file_uri(relative_file_path) - assert file_buffer.uri == expected_uri, f"Inconsistency between provided {file_buffer.uri=} and {expected_uri=}" - if open_in_ls: - file_buffer.ensure_open_in_ls() - yield file_buffer - else: - with self.open_file(relative_file_path, open_in_ls=open_in_ls) as fb: - yield fb - - def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position: - """ - Insert text at the given line and column in the given file and return - the updated cursor position after inserting the text. - - :param relative_file_path: The relative path of the file to open. - :param line: The line number at which text should be inserted. - :param column: The column number at which text should be inserted. - :param text_to_be_inserted: The text to insert. - """ - if not self.server_started: - log.error("insert_text_at_position called before Language Server started") - raise SolidLSPException("Language Server not started") - - uri = self._resolve_file_uri(relative_file_path) - - # Ensure the file is open - assert uri in self.open_file_buffers - - file_buffer = self.open_file_buffers[uri] - file_buffer.version += 1 - - new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted) - file_buffer.contents = new_contents - self.server.notify.did_change_text_document( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.VERSION: file_buffer.version, - LSPConstants.URI: file_buffer.uri, - }, - LSPConstants.CONTENT_CHANGES: [ - { - LSPConstants.RANGE: { - "start": {"line": line, "character": column}, - "end": {"line": line, "character": column}, - }, - "text": text_to_be_inserted, - } - ], - } - ) - return ls_types.Position(line=new_l, character=new_c) - - def delete_text_between_positions( - self, - relative_file_path: str, - start: ls_types.Position, - end: ls_types.Position, - ) -> str: - """ - Delete text between the given start and end positions in the given file and return the deleted text. - """ - if not self.server_started: - log.error("delete_text_between_positions called before Language Server started") - raise SolidLSPException("Language Server not started") - - uri = self._resolve_file_uri(relative_file_path) - - # Ensure the file is open - assert uri in self.open_file_buffers - - file_buffer = self.open_file_buffers[uri] - file_buffer.version += 1 - new_contents, deleted_text = TextUtils.delete_text_between_positions( - file_buffer.contents, start_line=start["line"], start_col=start["character"], end_line=end["line"], end_col=end["character"] - ) - file_buffer.contents = new_contents - self.server.notify.did_change_text_document( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.VERSION: file_buffer.version, - LSPConstants.URI: file_buffer.uri, - }, - LSPConstants.CONTENT_CHANGES: [{LSPConstants.RANGE: {"start": start, "end": end}, "text": ""}], - } - ) - return deleted_text - - def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: - return self.server.send.definition(definition_params) - - class SymbolLocationRequest(ABC): - def __init__( - self, - language_server: "SolidLanguageServer", - relative_file_path: str, - line: int, - column: int, - *, - request_name: str, - ) -> None: - self.language_server = language_server - self.relative_file_path = relative_file_path - self.line = line - self.column = column - self.request_name = request_name - self.skip_ignored_paths = True - - def execute(self) -> list[ls_types.Location]: - self._ensure_server_started() - - t0 = perf_counter() if _debug_enabled else None - # arm indexing tracking before didOpen so that the subsequent wait can - # observe the indexing progress triggered by opening the file - self.language_server._pre_open_for_cross_file_references() - with self.language_server.open_file(self.relative_file_path): - self.language_server._wait_for_cross_file_references_if_needed() - try: - response = self.send_request() - except Exception as e: - mapped_exception = self.map_exception(e) - if mapped_exception is not None: - raise mapped_exception from e - raise - - result = self.normalize_response(response) - if t0 is not None: - self.log_perf_result(t0, result) - return result - - def _ensure_server_started(self) -> None: - if not self.language_server.server_started: - log.error("%s called before language server started", self.request_name) - raise SolidLSPException("Language Server not started") - - @abstractmethod - def send_request(self) -> object | None: - pass - - def map_exception(self, error: Exception) -> Exception | None: - if isinstance(error, LSPError) and getattr(error, "code", None) == -32603: - return RuntimeError( - f"LSP internal error (-32603) when requesting {self.request_name} for " - f"{self.relative_file_path}:{self.line}:{self.column}. " - "This often occurs when requesting a symbol in a way the language server cannot resolve." - ) - return None - - @abstractmethod - def normalize_response(self, response: object | None) -> list[ls_types.Location]: - pass - - def convert_location_item(self, item: dict[str, object], *, allow_location_links: bool = False) -> ls_types.Location | None: - if LSPConstants.URI in item and LSPConstants.RANGE in item: - uri = cast(str, item[LSPConstants.URI]) - range_d = cast(ls_types.Range, item[LSPConstants.RANGE]) - elif ( - allow_location_links - and LSPConstants.TARGET_URI in item - and LSPConstants.TARGET_RANGE in item - and LSPConstants.TARGET_SELECTION_RANGE in item - ): - uri = cast(str, item[LSPConstants.TARGET_URI]) - range_d = cast(ls_types.Range, item[LSPConstants.TARGET_SELECTION_RANGE]) - else: - raise AssertionError(f"Unexpected response from Language Server: {item}") - - abs_path = PathUtils.uri_to_path(uri) - rel_path_str = PathUtils.get_relative_path(abs_path, self.language_server.repository_root_path) - - if rel_path_str is None: - log.warning( - "Found a %s in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " - "Path: %s. This is a bug but we currently simply skip these locations.", - self.request_name, - abs_path, - ) - return None - - # skip locations of generated files that are not present on disk - # (language servers may report e.g. compiled classes under build output) - if not os.path.exists(abs_path): - log.info("%s found symbol at non-existent path: %s", self.request_name, abs_path) - return None - - if self.skip_ignored_paths and self.language_server.is_ignored_path(rel_path_str): - log.info("%s found symbol in ignored path: %s", self.request_name, rel_path_str) - return None - - return ls_types.Location(uri=uri, range=range_d, absolutePath=str(abs_path), relativePath=rel_path_str) - - def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None: - return - - class DefinitionLocationRequest(SymbolLocationRequest): - def __init__( - self, - language_server: "SolidLanguageServer", - relative_file_path: str, - line: int, - column: int, - *, - request_name: str = "request_definition", - ) -> None: - super().__init__( - language_server, - relative_file_path, - line, - column, - request_name=request_name, - ) - - def send_request(self) -> object | None: - return self.language_server._send_definition_request( - self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column) - ) - - def normalize_response(self, response: object | None) -> list[ls_types.Location]: - if response is None: - log.warning( - "Language server returned None for %s request at %s:%s:%s", - self.request_name, - self.relative_file_path, - self.line, - self.column, - ) - return [] - - ret: list[ls_types.Location] = [] - if isinstance(response, list): - for item in response: - assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" - if location := self.convert_location_item(cast(dict[str, object], item), allow_location_links=True): - ret.append(location) - return ret - - if isinstance(response, dict): - if location := self.convert_location_item(cast(dict[str, object], response), allow_location_links=True): - ret.append(location) - return ret - - assert False, f"Unexpected response from Language Server: {response}" - - def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: - """ - Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server - for the symbol at the given line and column in the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up - :param line: The line number of the symbol - :param column: The column number of the symbol - - :return: the list of locations where the symbol is defined - """ - request = self.DefinitionLocationRequest(self, relative_file_path, line, column) - return request.execute() - - def _send_implementation_request(self, implementation_params: ImplementationParams) -> Definition | list[LocationLink] | None: - return self.server.send.implementation(implementation_params) - - def _create_text_document_position_params(self, relative_file_path: str, line: int, column: int) -> DefinitionParams: - return cast( - DefinitionParams, - { - LSPConstants.TEXT_DOCUMENT: {LSPConstants.URI: self._resolve_file_uri(relative_file_path)}, - LSPConstants.POSITION: { - LSPConstants.LINE: line, - LSPConstants.CHARACTER: column, - }, - }, - ) - - def _pre_open_for_cross_file_references(self) -> None: - """Called just before open_file() for requests that may need cross-file indexing. - - Override to pre-arm async indexing tracking before sending didOpen (e.g. clear - a threading.Event so a subsequent wait for indexing blocks correctly). - The base implementation is a no-op. - """ - - def _wait_for_cross_file_references_if_needed(self) -> None: - if not self._has_waited_for_cross_file_references: - # Some LS require waiting for a while before they can return accurate cross-file results. - # The waiting has to happen after at least one file was opened in the LS. - sleep(self._get_wait_time_for_cross_file_referencing()) - self._has_waited_for_cross_file_references = True - - class ImplementationLocationRequest(DefinitionLocationRequest): - def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None: - super().__init__( - language_server, - relative_file_path, - line, - column, - request_name="request_implementation", - ) - - def send_request(self) -> object | None: - return self.language_server._send_implementation_request( - self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column), - ) - - def request_implementation(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: - """ - Raise a [textDocument/implementation](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_implementation) - request to the Language Server for the symbol at the given line and column in the given file. - - :param relative_file_path: The relative path of the file that has the symbol for which implementations should be looked up - :param line: The line number of the symbol - :param column: The column number of the symbol - :return: the list of locations where the symbol is implemented - """ - request = self.ImplementationLocationRequest(self, relative_file_path, line, column) - return request.execute() - - # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses - def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: - return self.server.send.references( - { - "textDocument": {"uri": self._resolve_file_uri(relative_file_path)}, - "position": {"line": line, "character": column}, - "context": {"includeDeclaration": False}, - } - ) - - class ReferencesLocationRequest(SymbolLocationRequest): - def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None: - super().__init__( - language_server, - relative_file_path, - line, - column, - request_name="request_references", - ) - - def send_request(self) -> object | None: - return self.language_server._send_references_request(self.relative_file_path, line=self.line, column=self.column) - - def normalize_response(self, response: object | None) -> list[ls_types.Location]: - if response is None: - return [] - - assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" - ret: list[ls_types.Location] = [] - for item in response: - assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" - if location := self.convert_location_item(cast(dict[str, object], item)): - ret.append(location) - return ret - - def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None: - elapsed_ms = (perf_counter() - t0) * 1000 - if not result: - log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", self.relative_file_path, elapsed_ms) - return - - unique_files = len({r["relativePath"] for r in result}) - log.debug( - "perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d", - self.relative_file_path, - elapsed_ms, - len(result), - unique_files, - ) - - def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: - """ - Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server - to find references to the symbol at the given line and column in the given file. Wait for the response and return the result. - Filters out references located in ignored directories. - - :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up - :param line: The line number of the symbol - :param column: The column number of the symbol - - :return: A list of locations where the symbol is referenced (excluding ignored directories) - """ - request = self.ReferencesLocationRequest(self, relative_file_path, line, column) - return request.execute() - - def retrieve_full_file_content(self, file_path: str) -> str: - """ - Retrieve the full content of the given file. - """ - if os.path.isabs(file_path): - file_path = os.path.relpath(file_path, self.repository_root_path) - with self.open_file(file_path) as file_data: - return file_data.contents - - def retrieve_content_around_line( - self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0 - ) -> MatchedConsecutiveLines: - """ - Retrieve the content of the given file around the given line. - - :param relative_file_path: The relative path of the file to retrieve the content from - :param line: The line number to retrieve the content around - :param context_lines_before: The number of lines to retrieve before the given line - :param context_lines_after: The number of lines to retrieve after the given line - - :return MatchedConsecutiveLines: A container with the desired lines. - """ - with self.open_file(relative_file_path) as file_data: - file_contents = file_data.contents - return MatchedConsecutiveLines.from_file_contents( - file_contents, - line=line, - context_lines_before=context_lines_before, - context_lines_after=context_lines_after, - source_file_path=relative_file_path, - ) - - def request_completions( - self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False - ) -> list[ls_types.CompletionItem]: - """ - Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server - to find completions at the given line and column in the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up - :param line: The line number of the symbol - :param column: The column number of the symbol - - :return: A list of completions - """ - with self.open_file(relative_file_path): - open_file_buffer = self.open_file_buffers[self._resolve_file_uri(relative_file_path)] - completion_params: LSPTypes.CompletionParams = { - "position": {"line": line, "character": column}, - "textDocument": {"uri": open_file_buffer.uri}, - "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked}, - } - response: list[LSPTypes.CompletionItem] | LSPTypes.CompletionList | None = None - - for _ in range(30): - response = self.server.send.completion(completion_params) - if isinstance(response, list): - response = {"items": response, "isIncomplete": False} - if response is None or not response["isIncomplete"]: - break - - # TODO: Understand how to appropriately handle `isIncomplete` - if response is None or (response["isIncomplete"] and not allow_incomplete): - return [] - - if "items" in response: - response = response["items"] - - response = cast(list[LSPTypes.CompletionItem], response) - - # TODO: Handle the case when the completion is a keyword - items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword] - - completions_list: list[ls_types.CompletionItem] = [] - - for item in items: - assert "insertText" in item or "textEdit" in item - assert "kind" in item - completion_item = {} - if "detail" in item: - completion_item["detail"] = item["detail"] - - if "label" in item: - completion_item["completionText"] = item["label"] - completion_item["kind"] = item["kind"] - elif "insertText" in item: - completion_item["completionText"] = item["insertText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "newText" in item["textEdit"]: - completion_item["completionText"] = item["textEdit"]["newText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "range" in item["textEdit"]: - new_dot_lineno, new_dot_colno = ( - completion_params["position"]["line"], - completion_params["position"]["character"], - ) - assert all( - ( - item["textEdit"]["range"]["start"]["line"] == new_dot_lineno, - item["textEdit"]["range"]["start"]["character"] == new_dot_colno, - item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"], - item["textEdit"]["range"]["start"]["character"] == item["textEdit"]["range"]["end"]["character"], - ) - ) - - completion_item["completionText"] = item["textEdit"]["newText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "insert" in item["textEdit"]: - assert False - else: - assert False - - completion_item = ls_types.CompletionItem(**completion_item) # type: ignore - completions_list.append(completion_item) - - return [json.loads(json_repr) for json_repr in set(json.dumps(item, sort_keys=True) for item in completions_list)] - - def _request_document_symbols( - self, relative_file_path: str, file_data: LSPFileBuffer | None - ) -> list[SymbolInformation] | list[DocumentSymbol] | None: - """ - Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) - request to the language server to find symbols in the given file - or returns a cached result if available. - The returned symbols are considered "raw document symbols" (in contrast to processed symbols returned by `request_document_symbols`). - - NOTE: This method can be overridden in subclasses to post-process the raw results. - When doing so after the initial implementation, be sure to update the init parameter `cache_version_raw_document_symbols` - to a different version (add 1) to ensure that all caches are invalidated appropriately. - IMPORTANT: Since rebuilding the raw document symbol cache from the language server results - is potentially expensive, prefer overriding the `request_document_symbols` method - if the post-processing can also be done on the processed/high-level symbols. - - :param relative_file_path: the relative path of the file that has the symbols. - :param file_data: the file data buffer, if already opened. If None, the file will be opened in this method. - :return: the list of root symbols in the file. - """ - - def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: - file_hash_and_result = self._raw_document_symbols_cache.get(cache_key) - if file_hash_and_result is None: - log.debug("No cache hit for raw document symbols in %s", relative_file_path) - log.debug("perf: raw_document_symbols_cache MISS path=%s", relative_file_path) - return None - - file_hash, result = file_hash_and_result - if file_hash == fd.content_hash: - log.debug("Returning cached raw document symbols for %s", relative_file_path) - log.debug("perf: raw_document_symbols_cache HIT path=%s", relative_file_path) - return result - - log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) - log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) - return None - - def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: - # check for cached result - cache_key = relative_file_path - response = get_cached_raw_document_symbols(cache_key, fd) - if response is not None: - return response - - # no cached result, query language server - log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server") - response = self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}}) - - # Only cache non-empty results. An empty or None response can occur when the language server - # has not yet finished indexing or building the project (e.g. Lean 4 before `lake build`), - # and caching it would permanently serve stale data even after the project is ready. - if response: - self._raw_document_symbols_cache[cache_key] = (fd.content_hash, response) - self._raw_document_symbols_cache_is_modified = True - - return response - - with self._open_file_context(relative_file_path, file_buffer=file_data) as fd: - return get_raw_document_symbols(fd) - - def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: - """ - Normalizes the name of the given symbol, e.g. by removing parameter lists from method symbols. - - Override this method in subclasses to implement language-specific normalization logic. - NOTE: When changing the override of this method after the initial LS implementation, - be sure to also override `_document_symbols_cache_fingerprint` in order to ensure that - the caches are invalidated appropriately. - - :param symbol: the symbol - :param relative_file_path: the relative path of the file the symbol is located in - :return: the normalized name of the symbol - """ - # the default implementation does not change the name - return symbol["name"] - - def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: - """ - Retrieves the collection of symbols in the given file. - - NOTE: This method can be overridden in subclasses to post-process the results. - When doing so after the initial LS implementation, be sure to also override `_document_symbols_cache_fingerprint` - to ensure that the caches are invalidated appropriately. - DO NOT override this method to modify symbol names; override `_normalize_symbol_name` instead. - - :param relative_file_path: The relative path of the file that has the symbols - :param file_buffer: an optional file buffer if the file is already opened. - :return: the collection of symbols in the file. - All contained symbols will have a location, children, and a parent attribute, - where the parent attribute is None for root symbols. - Note that this is slightly different from the call to request_full_symbol_tree, - where the parent attribute will be the file symbol which in turn may have a package symbol as parent. - If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead. - """ - with self._open_file_context(relative_file_path, file_buffer, open_in_ls=False) as file_data: - # check if the desired result is cached - cache_key = relative_file_path - file_hash_and_result = self._document_symbols_cache.get(cache_key) - if file_hash_and_result is None: - log.debug("No cache hit for document symbols in %s", relative_file_path) - log.debug("perf: document_symbols_cache MISS path=%s", relative_file_path) - else: - file_hash, document_symbols = file_hash_and_result - if file_hash == file_data.content_hash: - log.debug("Returning cached document symbols for %s", relative_file_path) - log.debug("perf: document_symbols_cache HIT path=%s", relative_file_path) - return document_symbols - - log.debug("Cached document symbol content for %s has changed", relative_file_path) - log.debug("perf: document_symbols_cache STALE path=%s", relative_file_path) - - # no cached result: request the root symbols from the language server - root_symbols = self._request_document_symbols(relative_file_path, file_data) - - if root_symbols is None: - log.warning( - f"Received None response from the Language Server for document symbols in {relative_file_path}. " - f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. " - f"Returning empty list", - ) - return DocumentSymbols([]) - - assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}" - log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path) - - body_factory = SymbolBodyFactory(file_data) - - def convert_to_unified_symbol(original_symbol_dict: RawDocumentSymbol) -> ls_types.UnifiedSymbolInformation: - """ - Converts the given symbol dictionary to the unified representation, ensuring - that all required fields are present (except 'children' which is handled separately). - - :param original_symbol_dict: the item to augment - :return: the augmented item (new object) - """ - # noinspection PyInvalidCast - item = cast(ls_types.UnifiedSymbolInformation, dict(original_symbol_dict)) - absolute_path = os.path.join(self.repository_root_path, relative_file_path) - - # handle missing location and path entries - if "location" not in item: - uri = pathlib.Path(absolute_path).as_uri() - assert "range" in item - tree_location = ls_types.Location( - uri=uri, - range=item["range"], - absolutePath=absolute_path, - relativePath=relative_file_path, - ) - item["location"] = tree_location - location = item["location"] - if "absolutePath" not in location: - location["absolutePath"] = absolute_path - if "relativePath" not in location: - location["relativePath"] = relative_file_path - - item["body"] = self.create_symbol_body(item, factory=body_factory) - - # handle missing selectionRange - if "selectionRange" not in item: - if "range" in item: - item["selectionRange"] = item["range"] - else: - item["selectionRange"] = item["location"]["range"] - - return item - - def convert_symbols_with_common_parent( - symbols: list[DocumentSymbol] | list[SymbolInformation], - parent: ls_types.UnifiedSymbolInformation | None, - ) -> list[ls_types.UnifiedSymbolInformation]: - """ - Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships, - adding overload indices for symbols with the same name under the same parent. - """ - # apply name normalization and count occurrences of each symbol name - total_name_counts: dict[str, int] = defaultdict(lambda: 0) - for symbol in symbols: - name = self._normalize_symbol_name(symbol, relative_file_path=relative_file_path) - symbol["name"] = name - total_name_counts[name] += 1 - - # convert symbols to the unified representation and - # * add overload indices where necessary - # * ensure that the "parent" field is set correctly - name_counts: dict[str, int] = defaultdict(lambda: 0) - unified_symbols = [] - for symbol in symbols: - usymbol = convert_to_unified_symbol(symbol) - if total_name_counts[usymbol["name"]] > 1: - usymbol["overload_idx"] = name_counts[usymbol["name"]] - name_counts[usymbol["name"]] += 1 - usymbol["parent"] = parent - if "children" in usymbol: - usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol) # type: ignore - else: - usymbol["children"] = [] - unified_symbols.append(usymbol) - return unified_symbols - - unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None) - document_symbols = DocumentSymbols(unified_root_symbols) - - # update cache - log.debug("Updating cached document symbols for %s", relative_file_path) - self._document_symbols_cache[cache_key] = (file_data.content_hash, document_symbols) - self._document_symbols_cache_is_modified = True - - return document_symbols - - def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[ls_types.UnifiedSymbolInformation]: - """ - Will go through all files in the project or within a relative path and build a tree of symbols. - Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search. - - For each file, a symbol of kind File (2) will be created. For directories, a symbol of kind Package (4) will be created. - All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project - that are within the repository. - All symbols except the root packages will have a parent attribute. - Will ignore directories starting with '.', language-specific defaults - and user-configured directories (e.g. from .gitignore). - - :param within_relative_path: pass a relative path to only consider symbols within this path. - If a file is passed, only the symbols within this file will be considered. - If a directory is passed, all files within this directory will be considered. - :return: A list of root symbols representing the top-level packages/modules in the project. - """ - - # Helper function to recursively process directories - def process_directory(abs_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: - abs_dir_path = os.path.realpath(abs_dir_path) - - rel_dir_path: str | None - try: - rel_dir_path = str(Path(abs_dir_path).relative_to(self.repository_root_path)) - except ValueError: # not relative to repository root - rel_dir_path = None - if rel_dir_path and self.is_ignored_path(rel_dir_path): - log.debug("Skipping directory: %s (because it should be ignored)", rel_dir_path) - return [] - - result = [] - try: - contained_dir_or_file_names = os.listdir(abs_dir_path) - except OSError: - return [] - - # Create package symbol for directory - package_symbol = ls_types.UnifiedSymbolInformation( - name=os.path.basename(abs_dir_path), - kind=ls_types.SymbolKind.Package, - location=ls_types.Location( - uri=str(pathlib.Path(abs_dir_path).as_uri()), - range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, - absolutePath=str(abs_dir_path), - relativePath=str(Path(abs_dir_path).resolve().relative_to(self.repository_root_path)), - ), - children=[], - ) - result.append(package_symbol) - - for contained_dir_or_file_name in contained_dir_or_file_names: - contained_dir_or_file_abs_path = os.path.join(abs_dir_path, contained_dir_or_file_name) - - # obtain relative path - try: - contained_dir_or_file_rel_path = str( - Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path) - ) - except ValueError as e: - # Typically happens when the path is not under the repository root (e.g., symlink pointing outside) - log.warning( - "Skipping path %s; likely outside of the repository root %s [cause: %s]", - contained_dir_or_file_abs_path, - self.repository_root_path, - e, - ) - continue - - if self.is_ignored_path(contained_dir_or_file_rel_path): - log.debug("Skipping item: %s (because it should be ignored)", contained_dir_or_file_rel_path) - continue - - if os.path.isdir(contained_dir_or_file_abs_path): - child_symbols = process_directory(contained_dir_or_file_abs_path) - package_symbol["children"].extend(child_symbols) - for child in child_symbols: - child["parent"] = package_symbol - - elif os.path.isfile(contained_dir_or_file_abs_path): - with self._open_file_context(contained_dir_or_file_rel_path, open_in_ls=False) as file_data: - document_symbols = self.request_document_symbols(contained_dir_or_file_rel_path, file_data) - file_root_nodes = document_symbols.root_symbols - - # Create file symbol, link with children - file_range = self._get_range_from_file_content(file_data.contents) - file_symbol = ls_types.UnifiedSymbolInformation( - name=os.path.splitext(contained_dir_or_file_name)[0], - kind=ls_types.SymbolKind.File, - range=file_range, - selectionRange=file_range, - location=ls_types.Location( - uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()), - range=file_range, - absolutePath=str(contained_dir_or_file_abs_path), - relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)), - ), - children=file_root_nodes, - parent=package_symbol, - ) - for child in file_root_nodes: - child["parent"] = file_symbol - - # Link file symbol with package - package_symbol["children"].append(file_symbol) - - # TODO: Not sure if this is actually still needed given recent changes to relative path handling - def fix_relative_path(nodes: list[ls_types.UnifiedSymbolInformation]) -> None: - for node in nodes: - if "location" in node and "relativePath" in node["location"]: - path = Path(node["location"]["relativePath"]) # type: ignore - if path.is_absolute(): - try: - path = path.relative_to(self.repository_root_path) - node["location"]["relativePath"] = str(path) - except Exception: - pass - if "children" in node: - fix_relative_path(node["children"]) - - fix_relative_path(file_root_nodes) - - return result - - if within_relative_path: - within_abs_path = os.path.join(self.repository_root_path, within_relative_path) - if not os.path.exists(within_abs_path): - raise FileNotFoundError(f"File or directory not found: {within_abs_path}") - if self.is_ignored_path(within_relative_path): - raise ValueError(f"Explicitly requested symbols in '{within_relative_path}' while the path is ignored") - if os.path.isfile(within_abs_path): - root_nodes = self.request_document_symbols(within_relative_path).root_symbols - return root_nodes - else: - self.PathWorkspaceStatus.from_abs_resolved_path(Path(within_abs_path).resolve(), self).check_within_workspace_or_raise() - return process_directory(within_abs_path) - else: - full_result = [] - for root in self.config.get_absolute_workspace_folders(self.repository_root_path): - full_result.extend(process_directory(root)) - return full_result - - @staticmethod - def _get_range_from_file_content(file_content: str) -> ls_types.Range: - """ - Get the range for the given file. - """ - lines = file_content.split("\n") - end_line = len(lines) - end_column = len(lines[-1]) - return ls_types.Range(start=ls_types.Position(line=0, character=0), end=ls_types.Position(line=end_line, character=end_column)) - - def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[UnifiedSymbolInformation]]: - """ - :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. - """ - symbol_tree = self.request_full_symbol_tree(relative_dir_path) - # Initialize result dictionary - result: dict[str, list[UnifiedSymbolInformation]] = defaultdict(list) - - # Helper function to process a symbol and its children - def process_symbol(symbol: ls_types.UnifiedSymbolInformation) -> None: - if symbol["kind"] == ls_types.SymbolKind.File: - # For file symbols, process their children (top-level symbols) - for child in symbol["children"]: - # Handle cross-platform path resolution (fixes Docker/macOS path issues) - absolute_path = Path(child["location"]["absolutePath"]).resolve() - repository_root = Path(self.repository_root_path).resolve() - - # Try pathlib first, fallback to alternative approach if paths are incompatible - try: - path = absolute_path.relative_to(repository_root) - except ValueError: - # If paths are from different roots (e.g., /workspaces vs /Users), - # use the relativePath from location if available, or extract from absolutePath - if "relativePath" in child["location"] and child["location"]["relativePath"]: - path = Path(child["location"]["relativePath"]) - else: - # Extract relative path by finding common structure - # Example: /workspaces/.../test_repo/file.py -> test_repo/file.py - path_parts = absolute_path.parts - - # Find the last common part or use a fallback - if "test_repo" in path_parts: - test_repo_idx = path_parts.index("test_repo") - path = Path(*path_parts[test_repo_idx:]) - else: - # Last resort: use filename only - path = Path(absolute_path.name) - result[str(path)].append(child) - # For package/directory symbols, process their children - for child in symbol["children"]: - process_symbol(child) - - # Process each root symbol - for root in symbol_tree: - process_symbol(root) - return result - - def request_document_overview(self, relative_file_path: str) -> list[UnifiedSymbolInformation]: - """ - :return: the top-level symbols in the given file. - """ - return self.request_document_symbols(relative_file_path).root_symbols - - def request_overview(self, within_relative_path: str) -> dict[str, list[UnifiedSymbolInformation]]: - """ - An overview of all symbols in the given file or directory. - Raises a ValueError if a path to an ignored file is passed. - - :param within_relative_path: the relative path to the file or directory to get the overview of. - :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. - """ - abs_path = (Path(self.repository_root_path) / within_relative_path).resolve() - if not abs_path.exists(): - raise FileNotFoundError(f"File or directory not found: {abs_path}") - - if abs_path.is_file(): - if self.is_ignored_path(within_relative_path): - raise ValueError(f"The explicitly passed file {within_relative_path} is ignored, not returning overview.") - symbols_overview = self.request_document_overview(within_relative_path) - return {within_relative_path: symbols_overview} - else: - return self.request_dir_overview(within_relative_path) - - def request_hover( - self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None - ) -> ls_types.Hover | None: - """ - Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server - to find the hover information at the given line and column in the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file that has the hover information - :param line: The line number of the symbol - :param column: The column number of the symbol - :param file_buffer: The file buffer to use for the request. If not provided, the file will be read from disk. - Can be used for optimizing number of file reads in downstream code - """ - with self._open_file_context(relative_file_path, file_buffer=file_buffer) as fb: - return self._request_hover(fb, line, column) - - def _request_hover(self, file_buffer: LSPFileBuffer, line: int, column: int) -> ls_types.Hover | None: - """ - Performs the actual hover request. - """ - response = self.server.send.hover( - { - "textDocument": {"uri": file_buffer.uri}, - "position": { - "line": line, - "character": column, - }, - } - ) - - if response is None: - return None - - assert isinstance(response, dict) - contents = response.get("contents") - if not contents: - return None - if isinstance(contents, dict) and not contents.get("value"): - return None - return ls_types.Hover(**response) # type: ignore - - def request_signature_help(self, relative_file_path: str, line: int, column: int) -> ls_types.SignatureHelp | None: - """ - Raise a [textDocument/signatureHelp](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp) - request to the Language Server to find the signature help at the given line and column in the given file. - Note: contrary to `hover`, this only returns something on the position of a *call* and not on a symbol definition. - This means for Serena's purposes, this method is not particularly useful. The result is also fairly verbose (but well structured). - - :param relative_file_path: The relative path of the file that has the signature help - :param line: The line number of the symbol - :param column: The column number of the symbol - - :return None - """ - with self.open_file(relative_file_path): - response = self.server.send.signature_help( - { - "textDocument": {"uri": self._resolve_file_uri(relative_file_path)}, - "position": { - "line": line, - "character": column, - }, - } - ) - - if response is None: - return None - - assert isinstance(response, dict) - - return ls_types.SignatureHelp(**response) # type: ignore - - def create_symbol_body( - self, - symbol: ls_types.UnifiedSymbolInformation, - factory: SymbolBodyFactory | None = None, - ) -> SymbolBody: - if factory is None: - assert "relativePath" in symbol["location"] - with self._open_file_context(symbol["location"]["relativePath"]) as f: # type: ignore - factory = SymbolBodyFactory(f) - - return factory.create_symbol_body(symbol) - - def request_referencing_symbols( - self, - relative_file_path: str, - line: int, - column: int, - include_imports: bool = True, - include_self: bool = False, - include_body: bool = False, - include_file_symbols: bool = False, - ) -> list[ReferenceInSymbol]: - """ - Finds all symbols that reference the symbol at the given location. - This is similar to request_references but filters to only include symbols - (functions, methods, classes, etc.) that reference the target symbol. - - :param relative_file_path: The relative path to the file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column number. - :param include_imports: whether to also include imports as references. - Unfortunately, the LSP does not have an import type, so the references corresponding to imports - will not be easily distinguishable from definitions. - :param include_self: whether to include the references that is the "input symbol" itself. - Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition. - :param include_body: whether to include the body of the symbols in the result. - :param include_file_symbols: whether to include references that are file symbols. This - is often a fallback mechanism for when the reference cannot be resolved to a symbol. - :return: List of objects containing the symbol and the location of the reference. - """ - if not self.server_started: - log.error("request_referencing_symbols called before Language Server started") - raise SolidLSPException("Language Server not started") - - # First, get all references to the symbol - references = self.request_references(relative_file_path, line, column) - if not references: - return [] - - debug_enabled = log.isEnabledFor(logging.DEBUG) - t0_loop = perf_counter() if debug_enabled else 0.0 - # For each reference, find the containing symbol - result = [] - incoming_symbol = None - for ref in references: - ref_path = ref["relativePath"] - assert ref_path is not None - ref_line = ref["range"]["start"]["line"] - ref_col = ref["range"]["start"]["character"] - - with self.open_file(ref_path) as file_data: - body_factory = SymbolBodyFactory(file_data) - - # Get the containing symbol for this reference - containing_symbol = self.request_containing_symbol( - ref_path, ref_line, ref_col, include_body=include_body, body_factory=body_factory - ) - if containing_symbol is None: - # TODO: HORRIBLE HACK! I don't know how to do it better for now... - # THIS IS BOUND TO BREAK IN MANY CASES! IT IS ALSO SPECIFIC TO PYTHON! - # Background: - # When a variable is used to change something, like - # - # instance = MyClass() - # instance.status = "new status" - # - # we can't find the containing symbol for the reference to `status` - # since there is no container on the line of the reference - # The hack is to try to find a variable symbol in the containing module - # by using the text of the reference to find the variable name (In a very heuristic way) - # and then look for a symbol with that name and kind Variable - ref_text = file_data.contents.split("\n")[ref_line] - if "." in ref_text: - containing_symbol_name = ref_text.split(".")[0] - document_symbols = self.request_document_symbols(ref_path) - for symbol in document_symbols.iter_symbols(): - if symbol["name"] == containing_symbol_name and symbol["kind"] == ls_types.SymbolKind.Variable: - containing_symbol = copy(symbol) - containing_symbol["location"] = ref - containing_symbol["range"] = ref["range"] - break - - # We failed retrieving the symbol, falling back to creating a file symbol - if containing_symbol is None and include_file_symbols: - log.warning(f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead") - fileRange = self._get_range_from_file_content(file_data.contents) - ref_abs_path = os.path.join(self.repository_root_path, ref_path) - if self._path_contains_dots(ref_path): - ref_abs_path = str(pathlib.Path(ref_abs_path).resolve()) - location = ls_types.Location( - uri=self._resolve_file_uri(ref_path), - range=fileRange, - absolutePath=ref_abs_path, - relativePath=ref_path, - ) - name = os.path.splitext(os.path.basename(ref_path))[0] - - containing_symbol = ls_types.UnifiedSymbolInformation( - kind=ls_types.SymbolKind.File, - range=fileRange, - selectionRange=fileRange, - location=location, - name=name, - children=[], - ) - - if include_body: - containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory) - - if containing_symbol is None or (not include_file_symbols and containing_symbol["kind"] == ls_types.SymbolKind.File): - continue - - assert "location" in containing_symbol - assert "selectionRange" in containing_symbol - - # Checking for self-reference - if ( - containing_symbol["location"]["relativePath"] == relative_file_path - and containing_symbol["selectionRange"]["start"]["line"] == ref_line - and containing_symbol["selectionRange"]["start"]["character"] == ref_col - ): - incoming_symbol = containing_symbol - if include_self: - result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) - continue - log.debug(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}") - continue - - # checking whether reference is an import - # This is neither really safe nor elegant, but if we don't do it, - # there is no way to distinguish between definitions and imports as import is not a symbol-type - # and we get the type referenced symbol resulting from imports... - if ( - not include_imports - and incoming_symbol is not None - and containing_symbol["name"] == incoming_symbol["name"] - and containing_symbol["kind"] == incoming_symbol["kind"] - ): - log.debug( - f"Found import of referenced symbol {incoming_symbol['name']}" - f"in {containing_symbol['location']['relativePath']}, skipping" - ) - continue - - result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) - - if debug_enabled: - loop_elapsed_ms = (perf_counter() - t0_loop) * 1000 - unique_files = len({r.symbol["location"]["relativePath"] for r in result}) - log.debug( - "perf: request_referencing_symbols path=%s loop_elapsed_ms=%.2f ref_count=%d result_count=%d unique_files=%d", - relative_file_path, - loop_elapsed_ms, - len(references), - len(result), - unique_files, - ) - - return result - - def request_containing_symbol( - self, - relative_file_path: str, - line: int, - column: int | None = None, - strict: bool = False, - include_body: bool = False, - body_factory: SymbolBodyFactory | None = None, - ) -> ls_types.UnifiedSymbolInformation | None: - """ - Finds the first symbol containing the position for the given file. - For Python, container symbols are considered to be those with kinds corresponding to - functions, methods, or classes (typically: Function (12), Method (6), Class (5)). - - The method operates as follows: - - Request the document symbols for the file. - - Filter symbols to those that start at or before the given line. - - From these, first look for symbols whose range contains the (line, column). - - If one or more symbols contain the position, return the one with the greatest starting position - (i.e. the innermost container). - - If none (strictly) contain the position, return the symbol with the greatest starting position - among those above the given line. - - If no container candidates are found, return None. - - :param relative_file_path: The relative path to the Python file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column (also called character). If not passed, the lookup will be based - only on the line. - :param strict: If True, the position must be strictly within the range of the symbol. - Setting to True is useful for example for finding the parent of a symbol, as with strict=False, - and the line pointing to a symbol itself, the containing symbol will be the symbol itself - (and not the parent). - :param include_body: Whether to include the body of the symbol in the result. - :return: The container symbol (if found) or None. - """ - # checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor - with self.open_file(relative_file_path): - absolute_file_path = os.path.join(self.repository_root_path, relative_file_path) - if self._path_contains_dots(relative_file_path): - absolute_file_path = str(pathlib.Path(absolute_file_path).resolve()) - content = FileUtils.read_file(absolute_file_path, self._encoding) - if content.split("\n")[line].strip() == "": - log.error(f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}") - return None - - document_symbols = self.request_document_symbols(relative_file_path) - - # make jedi and pyright api compatible - # the former has no location, the later has no range - # we will just always add location of the desired format to all symbols - for symbol in document_symbols.iter_symbols(): - if "location" not in symbol: - range = symbol["range"] - location = ls_types.Location( - uri=f"file:/{absolute_file_path}", - range=range, - absolutePath=absolute_file_path, - relativePath=relative_file_path, - ) - symbol["location"] = location - else: - location = symbol["location"] - assert "range" in location - location["absolutePath"] = absolute_file_path - location["relativePath"] = relative_file_path - location["uri"] = Path(absolute_file_path).as_uri() - - # Allowed container kinds, currently only for Python - container_symbol_kinds = {ls_types.SymbolKind.Method, ls_types.SymbolKind.Function, ls_types.SymbolKind.Class} - - def is_position_in_range(line: int, range_d: ls_types.Range) -> bool: - start = range_d["start"] - end = range_d["end"] - - column_condition = True - if strict: - line_condition = end["line"] >= line > start["line"] - if column is not None and line == start["line"]: - column_condition = column > start["character"] - else: - line_condition = end["line"] >= line >= start["line"] - if column is not None and line == start["line"]: - column_condition = column >= start["character"] - return line_condition and column_condition - - # Only consider containers that are not one-liners (otherwise we may get imports) - candidate_containers = [ - s - for s in document_symbols.iter_symbols() - if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"] - ] - var_containers = [s for s in document_symbols.iter_symbols() if s["kind"] == ls_types.SymbolKind.Variable] - candidate_containers.extend(var_containers) - - if not candidate_containers: - return None - - # From the candidates, find those whose range contains the given position. - containing_symbols = [] - for symbol in candidate_containers: - s_range = symbol["location"]["range"] - if not is_position_in_range(line, s_range): - continue - containing_symbols.append(symbol) - - if containing_symbols: - # Return the one with the greatest starting position (i.e. the innermost container). - containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"]) - if include_body: - containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory) - return containing_symbol - else: - return None - - def request_container_of_symbol( - self, symbol: ls_types.UnifiedSymbolInformation, include_body: bool = False - ) -> ls_types.UnifiedSymbolInformation | None: - """ - Finds the container of the given symbol if there is one. If the parent attribute is present, the parent is returned - without further searching. - - :param symbol: The symbol to find the container of. - :param include_body: whether to include the body of the symbol in the result. - :return: The container of the given symbol or None if no container is found. - """ - if "parent" in symbol: - return symbol["parent"] - assert "location" in symbol, f"Symbol {symbol} has no location and no parent attribute" - return self.request_containing_symbol( - symbol["location"]["relativePath"], # type: ignore - symbol["location"]["range"]["start"]["line"], - symbol["location"]["range"]["start"]["character"], - strict=True, - include_body=include_body, - ) - - def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: - """ - Select the preferred definition from a list of definitions. - - When multiple definitions are returned (e.g., both source and type definitions), - this method determines which one to use. The base implementation simply returns - the first definition. - - Subclasses can override this method to implement language-specific preferences. - For example, TypeScript/Vue servers may prefer source files over .d.ts type - definition files. - - :param definitions: A non-empty list of definition locations. - :return: The preferred definition location. - """ - return definitions[0] - - def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ls_types.UnifiedSymbolInformation]: - abs_path = os.path.join(self.repository_root_path, relative_file_path) - if self._path_contains_dots(relative_file_path): - abs_path = str(pathlib.Path(abs_path).resolve()) - document_symbols = self.request_document_symbols(relative_file_path) - symbols = list(document_symbols.iter_symbols()) - - # Make SymbolInformation and DocumentSymbol shapes consistent by ensuring every - # symbol exposes a normalized location/range in the current workspace. - for symbol in symbols: - location = symbol["location"] - location["absolutePath"] = abs_path - location["relativePath"] = relative_file_path - location["uri"] = self._resolve_file_uri(relative_file_path) - return symbols - - @staticmethod - def _position_matches_range(range_d: ls_types.Range, line: int, column: int | None = None) -> bool: - start = range_d["start"] - end = range_d["end"] - if not (start["line"] <= line <= end["line"]): - return False - if column is None: - return True - if line == start["line"] and column < start["character"]: - return False - if line == end["line"] and column > end["character"]: - return False - return True - - @staticmethod - def _symbol_match_sort_key(symbol: ls_types.UnifiedSymbolInformation, match_priority: int) -> tuple[int, int, int, int, int]: - location = symbol["location"] - symbol_range = location["range"] - start = symbol_range["start"] - end = symbol_range["end"] - line_span = end["line"] - start["line"] - character_span = end["character"] - start["character"] if line_span == 0 else end["character"] - return match_priority, line_span, character_span, start["line"], start["character"] - - def _request_symbol_at_location( - self, - relative_file_path: str, - line: int, - column: int, - include_body: bool = False, - body_factory: SymbolBodyFactory | None = None, - ) -> ls_types.UnifiedSymbolInformation | None: - candidates: list[tuple[tuple[int, int, int, int, int], ls_types.UnifiedSymbolInformation]] = [] - for symbol in self._get_document_symbols_with_locations(relative_file_path): - location = symbol["location"] - symbol_range = location["range"] - selection_range = symbol.get("selectionRange") or symbol_range - - match_priority: int | None = None - if self._position_matches_range(selection_range, line, column): - match_priority = 0 - elif self._position_matches_range(symbol_range, line, column): - match_priority = 1 - else: - selection_start = selection_range["start"] - symbol_start = symbol_range["start"] - if (selection_start["line"], selection_start["character"]) == (line, column): - match_priority = 2 - elif (symbol_start["line"], symbol_start["character"]) == (line, column): - match_priority = 3 - elif selection_start["line"] == line and column <= selection_start["character"]: - match_priority = 4 - elif symbol_start["line"] == line and column <= symbol_start["character"]: - match_priority = 5 - - if match_priority is None: - continue - candidates.append((self._symbol_match_sort_key(symbol, match_priority), symbol)) - - if not candidates: - return None - - candidates.sort(key=lambda item: item[0]) - best_symbol = candidates[0][1] - if include_body: - best_symbol["body"] = self.create_symbol_body(best_symbol, factory=body_factory) - return best_symbol - - @staticmethod - def _iter_symbol_descendants(symbol: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]: - """Yield descendant symbols in depth-first order.""" - for child in symbol.get("children", []): - yield child - yield from SolidLanguageServer._iter_symbol_descendants(child) - - def _refine_implementing_symbol( - self, - target_symbol: ls_types.UnifiedSymbolInformation | None, - implementing_symbol: ls_types.UnifiedSymbolInformation, - include_body: bool = False, - ) -> ls_types.UnifiedSymbolInformation: - """Resolve member-level implementation symbols when the LS returns a containing type.""" - if target_symbol is None: - return implementing_symbol - - target_kind = target_symbol["kind"] - if target_kind not in (ls_types.SymbolKind.Method, ls_types.SymbolKind.Function): - return implementing_symbol - - if implementing_symbol["kind"] == target_kind and implementing_symbol.get("name") == target_symbol.get("name"): - return implementing_symbol - - candidate_descendants: list[ls_types.UnifiedSymbolInformation] = [] - for descendant in self._iter_symbol_descendants(implementing_symbol): - if descendant.get("name") != target_symbol.get("name"): - continue - if descendant["kind"] != target_kind: - continue - candidate_descendants.append(descendant) - - if not candidate_descendants: - return implementing_symbol - - refined_symbol = min( - candidate_descendants, - key=lambda symbol: self._symbol_match_sort_key(symbol, match_priority=0), - ) - if include_body: - refined_symbol["body"] = self.create_symbol_body(refined_symbol) - return refined_symbol - - def request_symbol_at_location( - self, - relative_file_path: str, - line: int, - column: int, - include_body: bool = False, - ) -> ls_types.UnifiedSymbolInformation | None: - """ - Finds the symbol at the given position, preferring exact identifier matches and otherwise - falling back to the innermost symbol whose body contains the position. - - :param relative_file_path: The relative path to the file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column number. - :param include_body: whether to include the body of the symbol in the result. - :return: The symbol at the given location, or None if no symbol could be resolved. - """ - if not self.server_started: - log.error("request_symbol_at_location called before language server started") - raise SolidLSPException("Language Server not started") - return self._request_symbol_at_location(relative_file_path, line, column, include_body=include_body) - - def request_defining_symbol( - self, - relative_file_path: str, - line: int, - column: int, - include_body: bool = False, - ) -> ls_types.UnifiedSymbolInformation | None: - """ - Finds the symbol that defines the symbol at the given location. - - This method first finds the definition of the symbol at the given position, - then retrieves the full symbol information for that definition. - - :param relative_file_path: The relative path to the file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column number. - :param include_body: whether to include the body of the symbol in the result. - :return: The symbol information for the definition, or None if not found. - """ - if not self.server_started: - log.error("request_defining_symbol called before language server started") - raise SolidLSPException("Language Server not started") - - # Get the definition location(s) - definitions = self.request_definition(relative_file_path, line, column) - if not definitions: - return None - - # Select the preferred definition (subclasses can override _get_preferred_definition) - definition = self._get_preferred_definition(definitions) - def_path = definition["relativePath"] - if def_path is None: - return None - def_line = definition["range"]["start"]["line"] - def_col = definition["range"]["start"]["character"] - - return self._request_symbol_at_location( - def_path, - def_line, - def_col, - include_body=include_body, - ) - - def request_implementing_symbols( - self, - relative_file_path: str, - line: int, - column: int, - include_body: bool = False, - ) -> list[ls_types.UnifiedSymbolInformation]: - """ - Finds the symbols that implement the symbol at the given location. - - This method first finds implementation locations for the symbol at the given position, - then retrieves the full symbol information for each implementation and de-duplicates - results that map to the same containing symbol. - - :param relative_file_path: The relative path to the file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column number. - :param include_body: whether to include the body of the symbols in the result. - :return: The symbol information for each implementation. - """ - if not self.server_started: - log.error("request_implementing_symbols called before language server started") - raise SolidLSPException("Language Server not started") - - target_symbol = self._request_symbol_at_location(relative_file_path, line, column, include_body=False) - implementation_locations = self.request_implementation(relative_file_path, line, column) - if not implementation_locations: - return [] - - result: list[ls_types.UnifiedSymbolInformation] = [] - seen_keys: set[tuple[str, int, int, int]] = set() - for implementation in implementation_locations: - implementation_path = implementation["relativePath"] - assert implementation_path is not None - implementation_line = implementation["range"]["start"]["line"] - implementation_col = implementation["range"]["start"]["character"] - implementing_symbol = self._request_symbol_at_location( - implementation_path, - implementation_line, - implementation_col, - include_body=include_body, - body_factory=None, - ) - if implementing_symbol is None: - continue - implementing_symbol = self._refine_implementing_symbol(target_symbol, implementing_symbol, include_body=include_body) - if "location" not in implementing_symbol: - continue - symbol_location = implementing_symbol["location"] - symbol_key = ( - cast(str, symbol_location["relativePath"]), - symbol_location["range"]["start"]["line"], - symbol_location["range"]["start"]["character"], - implementing_symbol["kind"], - ) - if symbol_key in seen_keys: - continue - seen_keys.add(symbol_key) - result.append(implementing_symbol) - - return result - - def _document_symbols_cache_fingerprint(self) -> Hashable | None: - """ - Returns a fingerprint of any language server-specific aspects that result in changes - to the high-level document symbol information. - - Language servers must implement this method/change the return value - * whenever they change the `request_document_symbols` implementation to modify the returned content - * are reconfigured in a way that affects the returned contents (e.g. context-specific configuration - such as build flags or environment variables); configuration options can, in such cases, be - hashed together to produce a single fingerprint value. - - Whenever the value changes, the document symbols cache will be invalidated and re-populated. - - The value must be hashable and safe for inclusion in cache version tuples. - E.g. use an integer, a string or a tuple of integers/strings. - - For example, if there is a single aspect being considered, use an integer to reflect the version - of this aspect (incrementing it whenever the implementation changes). - If multiple versioned aspects exist, use a tuple of versions, etc. - """ - return None - - def _document_symbols_cache_version(self) -> Hashable: - """ - Return the version for the document symbols cache. - - Incorporates cache context fingerprint if provided by the language server. - """ - fingerprint = self._document_symbols_cache_fingerprint() - if fingerprint is not None: - return (self.DOCUMENT_SYMBOL_CACHE_VERSION, fingerprint) - return self.DOCUMENT_SYMBOL_CACHE_VERSION - - def _save_raw_document_symbols_cache(self) -> None: - cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME - - if not self._raw_document_symbols_cache_is_modified: - log.debug("No changes to raw document symbols cache, skipping save") - return - - log.info("Saving updated raw document symbols cache to %s", cache_file) - try: - save_cache(str(cache_file), self._raw_document_symbols_cache_version(), self._raw_document_symbols_cache) - self._raw_document_symbols_cache_is_modified = False - except Exception as e: - log.error( - "Failed to save raw document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.", - cache_file, - e, - ) - - def _raw_document_symbols_cache_version(self) -> tuple[Hashable, ...]: - base_version: tuple[Hashable, ...] = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version) - fingerprint = self._document_symbols_cache_fingerprint() - if fingerprint is not None: - return (*base_version, fingerprint) - return base_version - - def _load_raw_document_symbols_cache(self) -> None: - cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME - - if not cache_file.exists(): - # check for legacy cache to load to migrate - legacy_cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK - if legacy_cache_file.exists(): - try: - legacy_cache: dict[ - str, tuple[str, tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]] - ] = load_pickle(legacy_cache_file) - log.info("Migrating legacy document symbols cache with %d entries", len(legacy_cache)) - num_symbols_migrated = 0 - migrated_cache = {} - for cache_key, (file_hash, (all_symbols, root_symbols)) in legacy_cache.items(): - if cache_key.endswith("-True"): # include_body=True - new_cache_key = cache_key[:-5] - migrated_cache[new_cache_key] = (file_hash, root_symbols) - num_symbols_migrated += len(all_symbols) - log.info("Migrated %d document symbols from legacy cache", num_symbols_migrated) - self._raw_document_symbols_cache = migrated_cache - self._raw_document_symbols_cache_is_modified = True - self._save_raw_document_symbols_cache() - legacy_cache_file.unlink() - return - except Exception as e: - log.error("Error during cache migration: %s", e) - return - - # load existing cache (if any) - if cache_file.exists(): - log.info("Loading document symbols cache from %s", cache_file) - try: - saved_cache = load_cache(str(cache_file), self._raw_document_symbols_cache_version()) - if saved_cache is not None: - self._raw_document_symbols_cache = saved_cache - log.info(f"Loaded {len(self._raw_document_symbols_cache)} entries from raw document symbols cache.") - except Exception as e: - # cache can become corrupt, so just skip loading it - log.warning( - "Failed to load raw document symbols cache from %s (%s); Ignoring cache.", - cache_file, - e, - ) - - def _save_document_symbols_cache(self) -> None: - cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME - - if not self._document_symbols_cache_is_modified: - log.debug("No changes to document symbols cache, skipping save") - return - - log.info("Saving updated document symbols cache to %s", cache_file) - try: - save_cache(str(cache_file), self._document_symbols_cache_version(), self._document_symbols_cache) - self._document_symbols_cache_is_modified = False - except Exception as e: - log.error( - "Failed to save document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.", - cache_file, - e, - ) - - def _load_document_symbols_cache(self) -> None: - cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME - if cache_file.exists(): - log.info("Loading document symbols cache from %s", cache_file) - try: - saved_cache = load_cache(str(cache_file), self._document_symbols_cache_version()) - if saved_cache is not None: - self._document_symbols_cache = saved_cache - log.info(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.") - except Exception as e: - # cache can become corrupt, so just skip loading it - log.warning( - "Failed to load document symbols cache from %s (%s); Ignoring cache.", - cache_file, - e, - ) - - def save_cache(self) -> None: - self._save_raw_document_symbols_cache() - self._save_document_symbols_cache() - - def request_workspace_symbol(self, query: str) -> list[ls_types.UnifiedSymbolInformation] | None: - """ - Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server - to find symbols across the whole workspace. Wait for the response and return the result. - - :param query: The query string to filter symbols by - - :return: A list of matching symbols - """ - response = self.server.send.workspace_symbol({"query": query}) - if response is None: - return None - - assert isinstance(response, list) - - ret: list[ls_types.UnifiedSymbolInformation] = [] - for item in response: - assert isinstance(item, dict) - - assert LSPConstants.NAME in item - assert LSPConstants.KIND in item - assert LSPConstants.LOCATION in item - - ret.append(ls_types.UnifiedSymbolInformation(**item)) # type: ignore - - return ret - - def request_rename_symbol_edit( - self, - relative_file_path: str, - line: int, - column: int, - new_name: str, - ) -> ls_types.WorkspaceEdit | None: - """ - Retrieve a WorkspaceEdit for renaming the symbol at the given location to the new name. - Does not apply the edit, just retrieves it. In order to actually rename the symbol, call apply_workspace_edit. - - :param relative_file_path: The relative path to the file containing the symbol - :param line: The 0-indexed line number of the symbol - :param column: The 0-indexed column number of the symbol - :param new_name: The new name for the symbol - :return: A WorkspaceEdit containing the changes needed to rename the symbol, or None if rename is not supported - """ - params = RenameParams( - textDocument=ls_types.TextDocumentIdentifier(uri=self._resolve_file_uri(relative_file_path)), - position=ls_types.Position(line=line, character=column), - newName=new_name, - ) - - with self.open_file(relative_file_path): - return self.server.send.rename(params) - - def apply_text_edits_to_file(self, relative_path: str, edits: list[ls_types.TextEdit]) -> None: - """ - Apply a list of text edits to a file. - - :param relative_path: The relative path of the file to edit - :param edits: List of TextEdit dictionaries to apply - """ - with self.open_file(relative_path): - # Sort edits by position (latest first) to avoid position shifts - sorted_edits = sorted(edits, key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]), reverse=True) - - for edit in sorted_edits: - start_pos = ls_types.Position(line=edit["range"]["start"]["line"], character=edit["range"]["start"]["character"]) - end_pos = ls_types.Position(line=edit["range"]["end"]["line"], character=edit["range"]["end"]["character"]) - - # Delete the old text and insert the new text - self.delete_text_between_positions(relative_path, start_pos, end_pos) - self.insert_text_at_position(relative_path, start_pos["line"], start_pos["character"], edit["newText"]) - - def start(self) -> "SolidLanguageServer": - """ - Starts the language server process and connects to it. Call shutdown when ready. - - :return: self for method chaining - """ - log.info(f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}") - self.server_started = True - self._start_server() - return self - - def stop(self, shutdown_timeout: float = 2.0) -> None: - """ - Stops the language server process. - This function never raises an exception (any exceptions during shutdown are logged). - - :param shutdown_timeout: time, in seconds, to wait for the server to shutdown gracefully before killing it - """ - try: - self.server.stop(timeout=shutdown_timeout) - except Exception as e: - log.warning(f"Exception while shutting down language server: {e}") - finally: - self.server_started = False - - @property - def language_server(self) -> Self: - return self - - @property - def handler(self) -> LanguageServerInterface: - """Access the underlying language server handler. - - Useful for advanced operations like sending custom commands - or registering notification handlers. - """ - return self.server - - def is_running(self) -> bool: - return self.server.is_running() - - def _create_initialize_params_builder(self) -> InitializeParamsBuilder: - return DefaultInitializeParamsBuilder(self) - - @abstractmethod - def _create_base_initialize_params(self) -> dict | InitializeParams: - """ - Subclasses should override this method to provide server-specific InitializeParams settings, - which provide the basis for the InitializeParams object sent to the language server during initialization. - - The returned dictionary is passed to the builder constructed by _create_initialize_params_builder() - in order to create the final InitializeParams object. - - The default builder implementation already sets the following keys, so implementations of this method - should not set them: - - - processId - - rootPath - - rootUri - - clientInfo - - workspaceFolders - - :return: the base InitializeParams settings - """ - - def _create_initialize_params(self) -> InitializeParams: - """ - Create the InitializeParams object to send to the language server during initialization. - """ - return self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index f296fee6..8731204d 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, Sequence +from collections.abc import Callable, Hashable, Iterator from contextlib import contextmanager from copy import copy from dataclasses import dataclass @@ -17,12 +17,19 @@ from time import monotonic, perf_counter, sleep from typing import Any, Self, Union, cast import pathspec +from sensai.util.helper import mark_used from sensai.util.pickle import getstate, load_pickle 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.dependency_provider import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderBaseCommand, + LanguageServerDependencyProviderSinglePath, + LanguageServerDependencyProviderUvx, +) from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException @@ -59,6 +66,14 @@ The `DocumentSymbol` is the preferred type, but the legacy type `SymbolInformati log = logging.getLogger(__name__) +# backward compatibility (support old imports of these classes from this module) +mark_used( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderBaseCommand, + LanguageServerDependencyProviderUvx, + LanguageServerDependencyProviderSinglePath, +) + _debug_enabled = log.isEnabledFor(logging.DEBUG) """Serves as a flag that triggers additional computation when debug logging is enabled.""" @@ -272,243 +287,6 @@ class DocumentSymbols: return self._all_symbols, self.root_symbols -class LanguageServerDependencyProvider(ABC): - """ - Prepares dependencies for a language server (if any), ultimately enabling the launch command to be constructed - and optionally providing environment variables that are necessary for the execution. - """ - - def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): - """ - :param custom_settings: the (user-provided) language server-specific settings - :param ls_resources_dir: the directory in which data for the language-server shall be stored - (this is already specific to the concrete language server, i.e. no further subdirectory is needed) - """ - self._custom_settings = custom_settings - self._ls_resources_dir = ls_resources_dir - - @abstractmethod - def create_launch_command(self) -> list[str]: - """ - Creates the launch command for this language server, potentially downloading and installing dependencies - beforehand. - - :return: the launch command as a list containing the executable and its arguments - """ - - def create_launch_command_env(self) -> dict[str, str]: - """ - Provides environment variables to be set when executing the launch command. - - This method is intended to be overridden by subclasses that need to set variables. - - :return: a mapping for variable names to values - """ - return {} - - -class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvider, ABC): - """ - Special case of a dependency provider, where the launch command is constructed from a base command. - - The user can configure aspects of the launch command in LS-specific settings: - - * ``ls_base_cmd`: overrides the base command - * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), - from which the base command is formed, bypassing Serena's managed installation - * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command - * ``ls_extra_args``: additional arguments to append to the launch command - """ - - @abstractmethod - def _create_default_base_command(self) -> list[str]: - """ - Obtains the default base command for this language server, potentially downloading and installing dependencies - beforehand. - - Note: The user can override the base command, so this will only be run if no custom base command is provided. - - :return: the base command as a list containing the executable and its arguments - """ - - @abstractmethod - def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: - """ - Adds any additional arguments to the base command to create the final launch command. - - :param base_command: the base command - :return: the extended command - """ - - def create_launch_command(self) -> list[str]: - # obtain base command - base_command = self._custom_settings.get("ls_base_cmd", None) - if base_command is not None and not isinstance(base_command, list): - log.warning("The 'ls_base_cmd' setting should be a list of strings. Ignoring the provided value: %s", base_command) - base_command = None - if base_command is None: - ls_path = self._custom_settings.get("ls_path", None) - if ls_path is not None: - base_command = [ls_path] - else: - # default case: base command is constructed by the provider implementation - base_command = self._create_default_base_command() - - # create launch command from base command - ls_args = self._custom_settings.get("ls_args") - if ls_args is not None: - if not isinstance(ls_args, list): - log.warning("The 'ls_args' setting should be a list of strings. Ignoring the provided value: %s", ls_args) - ls_args = None - if ls_args is not None: - cmd = list(base_command) + ls_args - else: - cmd = self._create_launch_command_from_base_command(list(base_command)) - - # add user-provided extra arguments (if any) - ls_extra_args = self._custom_settings.get("ls_extra_args", []) - if ls_extra_args: - if not isinstance(ls_extra_args, list): - log.warning("The 'ls_extra_args' setting should be a list of strings. Ignoring the provided value: %s", ls_extra_args) - else: - cmd = cmd + ls_extra_args - - return cmd - - -class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC): - """ - Special case of a dependency provider, where there is a single core dependency which provides - the basis for the launch command. - - The core dependency's path can be overridden by the user in LS-specific settings (SerenaConfig) - via the key "ls_path". If the user provides the key, the specified path is used directly. - Otherwise, the provider implementation is called to get or install the core dependency. - - Note: The inheritance from the BaseCommand class serves to allow user overrides to be handled - centrally, but implementations of this class do not necessarily follow the principle that - the base command is strictly extended. - Yet incompatibility arises only if (a) the user overrides the base command with a command that - has arguments, and (b) the implementation of this class does not construct the launch command - by appending arguments to the core dependency's path. - """ - - @abstractmethod - def _get_or_install_core_dependency(self) -> str: - """ - Gets the language server's core path, potentially installing dependencies beforehand. - - :return: the core dependency's path (e.g. executable, jar, etc.) - """ - - @abstractmethod - def _create_launch_command(self, core_path: str) -> list[str]: - """ - :param core_path: path to the core dependency - :return: the launch command as a list containing the executable and its arguments - """ - - def _create_default_base_command(self) -> list[str]: - # We treat the core path as the only element of the base command, - # noting that the construction of the launch command from the base command - # is not necessarily to append arguments only. - core_path = self._get_or_install_core_dependency() - return [core_path] - - def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: - core_path = base_command[0] - cmd = self._create_launch_command(core_path) - if len(base_command) == 1: - # This is the regular case, where the base command consists only of the core - # dependency's path, and the launch command is constructed from it. - return cmd - else: - # In this case, the user has overridden the base command via LS-specific settings - # with a command that has arguments and therefore does not map cleanly to - # the single path assumption. However, in special cases where the full command - # was constructed by appending arguments to the core dependency's path, - # we simply assume that the provided base command can be substituted. - if cmd[0] == core_path: - return base_command + cmd[1:] - else: - raise ValueError("Language server base launch command with arguments unsupported") - - -class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCommand): - """ - 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. - """ - - DEFAULT_UVX_PYTHON_VERSION = "3.13" - - 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) - - @staticmethod - def _build_uvx_base_command( - package: str, - version: str, - entrypoint: 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 python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing). - """ - base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint] - - 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 _create_default_base_command(self): - version = self._custom_settings.get(self._version_setting_key, self._default_version) - return self._build_uvx_base_command(self._package, version, self._entrypoint) - - def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: - return base_command + list(self._extra_args) - - class SolidLanguageServer(ABC): """ High-level abstraction for language server interaction, which wraps the underlying low-level LSP interface From 29575785eba28463938663ec63957b2b0b9d157b Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 20:07:50 +0200 Subject: [PATCH 119/170] Clean up LanguageServerInterface creation --- .../language_servers/godot_language_server.py | 10 ++---- src/solidlsp/ls.py | 34 +++++++++++-------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/solidlsp/language_servers/godot_language_server.py b/src/solidlsp/language_servers/godot_language_server.py index d0ea8c3f..fe3df8b5 100644 --- a/src/solidlsp/language_servers/godot_language_server.py +++ b/src/solidlsp/language_servers/godot_language_server.py @@ -13,7 +13,7 @@ from collections.abc import Callable from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_process import LanguageServerInterface, TCPConnectionInfo, TCPLanguageServer -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict +from solidlsp.lsp_protocol_handler.server import StringDict from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -47,7 +47,7 @@ class GodotLanguageServer(SolidLanguageServer): self._configured_request_timeout: float | None = None # Dummy ProcessLaunchInfo — _create_language_server_interface() ignores it - super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=""), "gdscript", solidlsp_settings) + super().__init__(config, repository_root_path, None, "gdscript", solidlsp_settings) def set_request_timeout(self, timeout: float | None) -> None: """Cap the timeout at the value configured in ls_specific_settings, if set.""" @@ -73,11 +73,7 @@ class GodotLanguageServer(SolidLanguageServer): pass return None - def _create_language_server_interface( - self, - process_launch_info: ProcessLaunchInfo, - logging_fn: Callable[[str, str, StringDict | str], None] | None, - ) -> LanguageServerInterface: + def _create_language_server_interface(self, logging_fn: Callable[[str, str, StringDict | str], None] | None) -> LanguageServerInterface: settings = self._custom_settings or {} port = settings.get("port", DEFAULT_GODOT_LS_PORT) request_timeout = settings.get("request_timeout", DEFAULT_GODOT_REQUEST_TIMEOUT) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index f296fee6..e38888ba 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -733,14 +733,10 @@ class SolidLanguageServer(ABC): else: logging_fn = None - # create the LanguageServerHandler, which provides the functionality to start the language server and communicate with it, - # preparing the launch command beforehand + # create the low-level server interface, potentially installing dependencies and launching a subprocess + self._process_launch_info: ProcessLaunchInfo | None = process_launch_info self._dependency_provider: LanguageServerDependencyProvider | None = None - if process_launch_info is None: - self._dependency_provider = self._create_dependency_provider() - process_launch_info = self._create_process_launch_info() - log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}") - self.server = self._create_language_server_interface(process_launch_info, logging_fn) + self.server = self._create_language_server_interface(logging_fn) """ the low-level language server interface """ @@ -786,15 +782,23 @@ class SolidLanguageServer(ABC): f"{self.__class__.__name__} must implement _create_dependency_provider() or pass process_launch_info to __init__()" ) + def _get_dependency_provider(self) -> LanguageServerDependencyProvider: + if self._dependency_provider is None: + self._dependency_provider = self._create_dependency_provider() + return self._dependency_provider + def _create_process_launch_info(self) -> ProcessLaunchInfo: - assert self._dependency_provider is not None - cmd = self._dependency_provider.create_launch_command() - env = self._dependency_provider.create_launch_command_env() + dependency_provider = self._get_dependency_provider() + cmd = dependency_provider.create_launch_command() + env = dependency_provider.create_launch_command_env() return ProcessLaunchInfo(cmd=cmd, cwd=self.repository_root_path, env=env) - def _create_language_server_interface( - self, process_launch_info: ProcessLaunchInfo, logging_fn: Callable[[str, str, StringDict | str], None] | None - ) -> LanguageServerInterface: + def _get_process_launch_info(self) -> ProcessLaunchInfo: + if self._process_launch_info is None: + self._process_launch_info = self._create_process_launch_info() + return self._process_launch_info + + def _create_language_server_interface(self, logging_fn: Callable[[str, str, StringDict | str], None] | None) -> LanguageServerInterface: """ Creates the low-level language server interface for LSP communication. @@ -803,10 +807,12 @@ class SolidLanguageServer(ABC): The default implementation creates a StdioLanguageServer, but subclasses can override this method to create a different type of interface if needed. - :param process_launch_info: process launch information :param logging_fn: the trace logging function :return: the interface """ + language_id = self.language_id + process_launch_info = self._get_process_launch_info() + log.debug(f"Creating language server instance with {language_id=} and {process_launch_info}") return StdioLanguageServer( process_launch_info, language=self.language, From 04d328c7483d26fffd06815fbafd36a3dab8ad17 Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:02:43 -0400 Subject: [PATCH 120/170] CI: add timeout-minutes backstop to the pytest matrix job The cpu matrix job had no timeout-minutes, so a hung job runs until GitHub's default 6-hour limit. This is not hypothetical: on run 29064865197 (2026-07-10), the native (ubuntu-latest) job hung and ran from 02:36:34 to 08:36:50 before GitHub cancelled it at exactly the 6-hour cap, blocking the PR's status the whole time and burning a runner for 6 hours. Healthy runs are far below the new limit: across the 8 most recent successful runs of this workflow, the slowest job was 16 minutes (niche on ubuntu-latest), with catch-all on windows-latest typically 11-14 minutes. 60 minutes leaves ~4x headroom for cache-cold runs (language-server downloads, npm installs) while cutting the worst-case hang from 6 hours to 1. Part of the timeout-hardening discussed in #1695. --- .github/workflows/pytest.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 60beb1ff..6cbcf36c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -56,6 +56,10 @@ jobs: cpu: name: ${{ matrix.batch }} (${{ matrix.os }}) runs-on: ${{ matrix.os }} + # Backstop against hung jobs: without this, GitHub's default limit is 6 hours (observed in + # run 29064865197, where a hung job burned the full 6h). Healthy runs peak at ~16 minutes + # warm-cache; 60 leaves ample headroom for cache-cold runs. + timeout-minutes: 60 # Marker groups are defined once here and combined in the "Compute marker expression" step. # The catch-all batch is derived as the negation of all named groups, so adding a language to # any group automatically removes it from catch-all -- the partition can never desync, and no From 9208a3ae877d4371ff3df6d6c5c2df8019155a8a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 16:25:54 +0200 Subject: [PATCH 121/170] Improve line/column computations, adhering to LSP standard * Add helper class TextStepper to facilitate computations * Reimplement TextUtils methods based on TextStepper * Adhere to LSP definition of newlines being defined by either "\n", "\r\n" or "\r" on its own. Previously, only "\n" was considered. * Add util method TextUtils.split_lines, allowing usages of Python's splitlines to be replaced --- CHANGELOG.md | 1 + src/solidlsp/ls_utils.py | 206 ++++++++++++++++++++++++++----- test/solidlsp/test_text_utils.py | 32 +++++ 3 files changed, 205 insertions(+), 34 deletions(-) create mode 100644 test/solidlsp/test_text_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4952c3bf..92adb837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Status of the `main` branch. Changes prior to the next official version change w - During project creation, language composition percentages are now computed relative to the total number of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage computation. + - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks) * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 0f47a3e7..321827bf 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -31,6 +31,114 @@ class InvalidTextLocationError(Exception): pass +class TextStepper: + def __init__(self, chars: str): + self._chars = chars + self._len = len(chars) + self.line = 0 + """ + the current 0-based line index + """ + self.col = 0 + """ + the current 0-based column index + """ + self.idx = 0 + """ + the current 0-based index in the full text. + The index specifies the next character to be processed, i.e. if this is + the length of the text, then the end of the text has been reached. + It specifies a location in the same way as a cursor insertion position: + cursor at the very beginning (idx=0) means insert before the first character. + """ + self._is_newline = False + """ + whether the last step was a line break + """ + self._prev_line_start_idx = 0 + """ + start index of the last fully processed line (inclusive) + """ + self._prev_line_end_idx = 0 + """ + end of the last fully processed line (exclusive), excluding newline characters + """ + self._line_start_idx = 0 + """ + start of the current, not yet completed line (inclusive) + """ + + def _get_char(self, idx: int) -> str | None: + if idx < 0 or idx >= self._len: + return None + return self._chars[idx] + + def step(self) -> bool: + """ + Processes the next character/newline sequence in the text + + :return: True if processing was possible, False if the end of the text was reached + """ + if self.idx >= self._len: + return False + + # process next character/newline sequence + # Note: LSP defines that a newline is given by either "\n", "\r\n", or "\r" on its own + # Reference: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocuments + idx_before = self.idx + c = self._chars[self.idx] + self.idx += 1 + is_newline = False + if c == "\n": + is_newline = True + elif c == "\r": + is_newline = True + if self._get_char(self.idx) == "\n": + self.idx += 1 # skip an additional character + + self._is_newline = is_newline + if is_newline: + self.line += 1 + self.col = 0 + self._prev_line_end_idx = idx_before + self._prev_line_start_idx = self._line_start_idx + self._line_start_idx = self.idx + else: + self.col += 1 + return True + + def process_all(self): + """ + Processes all characters in the text, updating the line and column numbers accordingly. + """ + while self.step(): + pass + + def _get_last_line(self, with_end: bool) -> str: + """ + Returns the last line processed, optionally including the newline character(s) at the end + """ + start_idx = self._prev_line_start_idx + end_idx = self._prev_line_end_idx if not with_end else self._line_start_idx + return self._chars[start_idx:end_idx] + + def process_all_gather_lines(self, with_ends: bool) -> list[str]: + """ + Processes all characters in the text and returns a list of lines + + :param with_ends: whether to include the newline character(s) at the end of each line + :return: the list of lines + """ + lines = [] + while self.step(): + if self._is_newline: + lines.append(self._get_last_line(with_end=with_ends)) + # add the last line (which was not followed by a newline), even if empty + last_line = self._chars[self._line_start_idx :] + lines.append(last_line) + return lines + + class TextUtils: """ Utilities for text operations. @@ -39,54 +147,68 @@ class TextUtils: @staticmethod def get_line_col_from_index(text: str, index: int) -> tuple[int, int]: """ - Returns the zero-indexed line and column number of the given index in the given text + :param text: the text in which the index is to be located + :param index: the 0-based index in the text + :return: a tuple (0-based line number, 0-based column number) corresponding to the index in the text """ - l = 0 - c = 0 - idx = 0 - while idx < index: - if text[idx] == "\n": - l += 1 - c = 0 - else: - c += 1 - idx += 1 + text_stepper = TextStepper(text) + while text_stepper.idx < index: + if not text_stepper.step(): + raise InvalidTextLocationError + return text_stepper.line, text_stepper.col - return l, c + @classmethod + def get_line_from_index(cls, text: str, index: int) -> int: + """ + :param text: the text in which the index is to be located + :param index: the 0-based index in the text + :return: the 0-based line number corresponding to the index in the text + """ + return cls.get_line_col_from_index(text, index)[0] @staticmethod def get_index_from_line_col(text: str, line: int, col: int) -> int: """ - Returns the index of the given zero-indexed line and column number in the given text + :param text: the text in which the coordinates are to be located + :param line: the 0-based line number + :param col: the 0-based column number + :return: the corresponding 0-based index in the text """ - idx = 0 - while line > 0: - if idx >= len(text): + text_stepper = TextStepper(text) + while text_stepper.line < line: + if not text_stepper.step(): raise InvalidTextLocationError - if text[idx] == "\n": - line -= 1 - idx += 1 - idx += col + idx = text_stepper.idx + col return idx @staticmethod def _get_updated_position_from_line_and_column_and_edit(l: int, c: int, text_to_be_inserted: str) -> tuple[int, int]: """ - Utility function to get the position of the cursor after inserting text at a given line and column. + :param l: the 0-based line number before the edit + :param c: the 0-based column number before the edit + :param text_to_be_inserted: the text that was inserted at the given position + :return: the updated 0-based line and column numbers after the edit (end of insertion) """ - num_newlines_in_gen_text = text_to_be_inserted.count("\n") - if num_newlines_in_gen_text > 0: - l += num_newlines_in_gen_text - c = len(text_to_be_inserted.split("\n")[-1]) + text_stepper = TextStepper(text_to_be_inserted) + text_stepper.process_all() + if text_stepper.line > 0: + l += text_stepper.line + c = text_stepper.col else: - c += len(text_to_be_inserted) - return (l, c) + c += text_stepper.col + return l, c @staticmethod def delete_text_between_positions(text: str, start_line: int, start_col: int, end_line: int, end_col: int) -> tuple[str, str]: """ Deletes the text between the given start and end positions. - Returns the modified text and the deleted text. + + :param text: the original text + :param start_line: the 0-based line number of the start position + :param start_col: the 0-based column number of the start position + :param end_line: the 0-based line number of the end position + :param end_col: the 0-based column number of the end position + :return: a tuple containing the modified text and the deleted text """ del_start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col) del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) @@ -98,13 +220,21 @@ class TextUtils: @staticmethod def insert_text_at_position(text: str, line: int, col: int, text_to_be_inserted: str) -> tuple[str, int, int]: """ - Inserts the given text at the given line and column. - Returns the modified text and the new line and column. + Inserts the given text at the given position and returns the + + :param text: the original text + :param line: the 0-based line number where the text should be inserted + :param col: the 0-based column number where the text should be inserted + :param text_to_be_inserted: the text to be inserted + :return: a tuple containing the modified text, the updated line number, and the updated column number + (position after the inserted text) """ try: change_index = TextUtils.get_index_from_line_col(text, line, col) except InvalidTextLocationError: - num_lines_in_text = text.count("\n") + 1 + text_stepper = TextStepper(text) + text_stepper.process_all() + num_lines_in_text = text_stepper.line + 1 max_line = num_lines_in_text - 1 if line == max_line + 1 and col == 0: # trying to insert at new line after full text # insert at end, adding missing newline @@ -125,14 +255,22 @@ class TextUtils: end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) return text[start_idx:end_idx] - @staticmethod - def get_text_in_lines_range(text: str, start_line: int, end_line: int) -> str: + @classmethod + def get_text_in_lines_range(cls, text: str, start_line: int, end_line: int) -> str: """ Returns the text encompassed by the given start and end lines (inclusive). """ - lines = text.splitlines(keepends=True) + lines = cls.split_lines(text, with_ends=True) return "".join(lines[start_line : end_line + 1]) + @staticmethod + def split_lines(text: str, with_ends: bool = False) -> list[str]: + """ + Splits the given text into lines, optionally including the newline character(s) at the end of each line. + """ + text_stepper = TextStepper(text) + return text_stepper.process_all_gather_lines(with_ends=with_ends) + class PathUtils: """ diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py new file mode 100644 index 00000000..fa937242 --- /dev/null +++ b/test/solidlsp/test_text_utils.py @@ -0,0 +1,32 @@ +from solidlsp.ls_utils import TextUtils + + +class TestTextUtils: + LINE = "012" + TEXT = LINE + "\n" + LINE + "\r\n" + LINE + "\r" + LINE + + def test_split_lines(self): + lines = TextUtils.split_lines(self.TEXT, with_ends=False) + assert len(lines) == 4 + for line in lines: + assert line == self.LINE + + def test_split_lines_with_ends(self): + lines = TextUtils.split_lines(self.TEXT, with_ends=True) + assert len(lines) == 4 + for i, line in enumerate(lines): + assert line[: len(self.LINE)] == self.LINE + for i, ending in enumerate(["\n", "\r\n", "\r", ""]): + assert lines[i][len(self.LINE) :] == ending + + def test_line_col_from_idx(self): + assert TextUtils.get_line_col_from_index(self.TEXT, 0) == (0, 0) + assert TextUtils.get_line_col_from_index(self.TEXT, 1) == (0, 1) + assert TextUtils.get_line_col_from_index(self.TEXT, 3 + 1 + 1) == (1, 1) + assert TextUtils.get_line_col_from_index(self.TEXT, 3 + 1 + 3 + 2 + 1) == (2, 1) + + def test_idx_from_line_col(self): + assert TextUtils.get_index_from_line_col(self.TEXT, 0, 0) == 0 + assert TextUtils.get_index_from_line_col(self.TEXT, 0, 1) == 1 + assert TextUtils.get_index_from_line_col(self.TEXT, 1, 1) == 3 + 1 + 1 + assert TextUtils.get_index_from_line_col(self.TEXT, 2, 1) == 3 + 1 + 3 + 2 + 1 From 97c9b8c05de975b5bb064d5102667850fcfa711a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 16:39:59 +0200 Subject: [PATCH 122/170] Apply TextUtils, ensuring consistent line numbering * in search_text * in MatchedConsecutiveLines.from_file_contents --- CHANGELOG.md | 4 +++- src/serena/util/text_utils.py | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92adb837..6c0005ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,9 @@ Status of the `main` branch. Changes prior to the next official version change w - During project creation, language composition percentages are now computed relative to the total number of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage computation. - - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks) + - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks); previously only "\n" considered + - Fix: Apply consistent line splitting across tools, uniformly applying the LSP splitting semantics; + affects `search_text` used by `search_for_pattern` tool (reported in #1684) * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 992c2437..8deb9856 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -100,7 +100,7 @@ class MatchedConsecutiveLines: def from_file_contents( cls, file_contents: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0, source_file_path: str | None = None ) -> Self: - line_contents = file_contents.split("\n") + line_contents = TextUtils.split_lines(file_contents) start_lineno = max(0, line - context_lines_before) end_lineno = min(len(line_contents) - 1, line + context_lines_after) text_lines: list[TextLine] = [] @@ -169,7 +169,7 @@ def search_text( raise ValueError("Pass either content or source_file_path") matches = [] - lines = content.splitlines() + lines = TextUtils.split_lines(content) total_lines = len(lines) # Convert pattern to a compiled regex if it's a string @@ -185,8 +185,8 @@ def search_text( end_pos = match.end() # Find the line numbers for the start and end positions - start_line_num = content[:start_pos].count("\n") - end_line_num = content[:end_pos].count("\n") + start_line_num = TextUtils.get_line_from_index(content, start_pos) + end_line_num = TextUtils.get_line_from_index(content, end_pos) # Calculate the range of lines to include in the context context_start = max(0, start_line_num - context_lines_before) From d4e99dc00309b6489b422295844b5a104a5efa0e Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 14 Jul 2026 23:09:49 +0200 Subject: [PATCH 123/170] Improve performance of TextStepper, stepping line by line instead of character by character, delegating the scan for line breaks to native string search (~15-100x speedup for a full pass over typical source files) --- src/solidlsp/ls_utils.py | 100 +++++++++++++++++++------------ test/solidlsp/test_text_utils.py | 2 + 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 321827bf..0f8ef140 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -51,19 +51,19 @@ class TextStepper: It specifies a location in the same way as a cursor insertion position: cursor at the very beginning (idx=0) means insert before the first character. """ - self._is_newline = False + self.is_newline = False """ - whether the last step was a line break + whether the last step processed a full line ending in a line break """ - self._prev_line_start_idx = 0 + self.prev_line_start_idx = 0 """ start index of the last fully processed line (inclusive) """ - self._prev_line_end_idx = 0 + self.prev_line_end_idx = 0 """ end of the last fully processed line (exclusive), excluding newline characters """ - self._line_start_idx = 0 + self.line_start_idx = 0 """ start of the current, not yet completed line (inclusive) """ @@ -73,53 +73,61 @@ class TextStepper: return None return self._chars[idx] - def step(self) -> bool: + def step_line(self) -> bool: """ - Processes the next character/newline sequence in the text + Processes the next line in the text, advancing past the next newline sequence or, + if no further newline is present, to the end of the text. - :return: True if processing was possible, False if the end of the text was reached + :return: True if processing was possible, False if the end of the text had already been reached + (idx not advanced) """ if self.idx >= self._len: return False - # process next character/newline sequence + # find the next newline sequence # Note: LSP defines that a newline is given by either "\n", "\r\n", or "\r" on its own # Reference: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocuments - idx_before = self.idx - c = self._chars[self.idx] - self.idx += 1 - is_newline = False - if c == "\n": - is_newline = True - elif c == "\r": - is_newline = True - if self._get_char(self.idx) == "\n": - self.idx += 1 # skip an additional character + # The search for "\r" is bounded by the position of the next "\n". + lf_idx = self._chars.find("\n", self.idx) + cr_idx = self._chars.find("\r", self.idx, lf_idx if lf_idx != -1 else self._len) + if cr_idx != -1: + newline_start_idx = cr_idx + newline_end_idx = cr_idx + 2 if self._get_char(cr_idx + 1) == "\n" else cr_idx + 1 + elif lf_idx != -1: + newline_start_idx = lf_idx + newline_end_idx = lf_idx + 1 + else: + newline_start_idx = None + newline_end_idx = None - self._is_newline = is_newline - if is_newline: + # advance past the newline sequence or, in its absence, consume the trailing line + if newline_start_idx is not None and newline_end_idx is not None: + self.idx = newline_end_idx self.line += 1 self.col = 0 - self._prev_line_end_idx = idx_before - self._prev_line_start_idx = self._line_start_idx - self._line_start_idx = self.idx + self.is_newline = True + self.prev_line_start_idx = self.line_start_idx + self.prev_line_end_idx = newline_start_idx + self.line_start_idx = newline_end_idx else: - self.col += 1 + self.idx = self._len + self.col = self._len - self.line_start_idx + self.is_newline = False return True def process_all(self): """ Processes all characters in the text, updating the line and column numbers accordingly. """ - while self.step(): + while self.step_line(): pass def _get_last_line(self, with_end: bool) -> str: """ Returns the last line processed, optionally including the newline character(s) at the end """ - start_idx = self._prev_line_start_idx - end_idx = self._prev_line_end_idx if not with_end else self._line_start_idx + start_idx = self.prev_line_start_idx + end_idx = self.prev_line_end_idx if not with_end else self.line_start_idx return self._chars[start_idx:end_idx] def process_all_gather_lines(self, with_ends: bool) -> list[str]: @@ -130,12 +138,14 @@ class TextStepper: :return: the list of lines """ lines = [] - while self.step(): - if self._is_newline: + while self.step_line(): + if self.is_newline: lines.append(self._get_last_line(with_end=with_ends)) + # add the last line (which was not followed by a newline), even if empty - last_line = self._chars[self._line_start_idx :] + last_line = self._chars[self.line_start_idx :] lines.append(last_line) + return lines @@ -151,10 +161,25 @@ class TextUtils: :param index: the 0-based index in the text :return: a tuple (0-based line number, 0-based column number) corresponding to the index in the text """ + # step over full lines; once the line containing the index has been processed, compute + # the position from the difference to the respective line's start index text_stepper = TextStepper(text) - while text_stepper.idx < index: - if not text_stepper.step(): - raise InvalidTextLocationError + while text_stepper.step_line(): + if text_stepper.idx > index: + if text_stepper.is_newline: + # position was stepped over as part of the previous line + if index > text_stepper.prev_line_end_idx: + # edge case: the index points into a multi-character newline sequence ("\r\n"); + # map this to the beginning of the following line + return text_stepper.line, 0 + return text_stepper.line - 1, index - text_stepper.prev_line_start_idx + else: + # position was stepped over as part of the current line (which was not followed by a newline) + return text_stepper.line, index - text_stepper.line_start_idx + + # handle the case where the end of the text was reached without stepping past the index + if index > text_stepper.idx: + raise InvalidTextLocationError return text_stepper.line, text_stepper.col @classmethod @@ -174,12 +199,13 @@ class TextUtils: :param col: the 0-based column number :return: the corresponding 0-based index in the text """ + # step over full lines until the requested line is reached text_stepper = TextStepper(text) while text_stepper.line < line: - if not text_stepper.step(): + if not text_stepper.step_line(): raise InvalidTextLocationError - idx = text_stepper.idx + col - return idx + + return text_stepper.line_start_idx + col @staticmethod def _get_updated_position_from_line_and_column_and_edit(l: int, c: int, text_to_be_inserted: str) -> tuple[int, int]: diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py index fa937242..4204f7e9 100644 --- a/test/solidlsp/test_text_utils.py +++ b/test/solidlsp/test_text_utils.py @@ -20,6 +20,8 @@ class TestTextUtils: assert lines[i][len(self.LINE) :] == ending def test_line_col_from_idx(self): + assert TextUtils.get_line_col_from_index(self.LINE, 0) == (0, 0) + assert TextUtils.get_line_col_from_index(self.LINE, 1) == (0, 1) assert TextUtils.get_line_col_from_index(self.TEXT, 0) == (0, 0) assert TextUtils.get_line_col_from_index(self.TEXT, 1) == (0, 1) assert TextUtils.get_line_col_from_index(self.TEXT, 3 + 1 + 1) == (1, 1) From c80d76abe9f3619ad18e7e83e937f03da0cb3a4b Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 15 Jul 2026 18:45:18 +0200 Subject: [PATCH 124/170] Fix edge case in insert_text_at_position when inserting in line beyond actual content (insertion coordinates were not adjusted to true end coordinates) --- src/solidlsp/ls_utils.py | 5 ++++- test/solidlsp/test_text_utils.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 0f8ef140..774d3741 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -263,9 +263,12 @@ class TextUtils: num_lines_in_text = text_stepper.line + 1 max_line = num_lines_in_text - 1 if line == max_line + 1 and col == 0: # trying to insert at new line after full text - # insert at end, adding missing newline + # insert at end, adding missing newline and adjusting insertion position + # to the actual end coordinates of the text change_index = len(text) text_to_be_inserted = "\n" + text_to_be_inserted + line = text_stepper.line + col = text_stepper.col else: raise new_text = text[:change_index] + text_to_be_inserted + text[change_index:] diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py index 4204f7e9..062125c4 100644 --- a/test/solidlsp/test_text_utils.py +++ b/test/solidlsp/test_text_utils.py @@ -32,3 +32,19 @@ class TestTextUtils: assert TextUtils.get_index_from_line_col(self.TEXT, 0, 1) == 1 assert TextUtils.get_index_from_line_col(self.TEXT, 1, 1) == 3 + 1 + 1 assert TextUtils.get_index_from_line_col(self.TEXT, 2, 1) == 3 + 1 + 3 + 2 + 1 + + def test_insert_text_at_index(self): + insertion = "XXX" + new_text, l, c = TextUtils.insert_text_at_position(self.TEXT, 0, 1, insertion) + assert (l, c) == (0, 1 + len(insertion)) + assert new_text.startswith("0XXX12") + + def test_insert_text_in_next_line_beyond_content(self): + """ + Test inserting text at a line index 1 beyond the actual number of lines. + This case is specifically handled as an edge case in the implementation. + """ + insertion = "XXX" + new_text, l, c = TextUtils.insert_text_at_position(self.TEXT, 4, 0, insertion) + assert (l, c) == (4, len(insertion)) + assert new_text == self.TEXT + "\n" + insertion From ba5ade0d4e7929c54f0c42361302b4f28d329e20 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 15 Jul 2026 18:50:07 +0200 Subject: [PATCH 125/170] Add location information when raising InvalidTextLocationError --- src/solidlsp/ls_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 774d3741..eafc3b37 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -179,7 +179,7 @@ class TextUtils: # handle the case where the end of the text was reached without stepping past the index if index > text_stepper.idx: - raise InvalidTextLocationError + raise InvalidTextLocationError(f"{index=}") return text_stepper.line, text_stepper.col @classmethod @@ -203,7 +203,7 @@ class TextUtils: text_stepper = TextStepper(text) while text_stepper.line < line: if not text_stepper.step_line(): - raise InvalidTextLocationError + raise InvalidTextLocationError("{line=}, {col=}") return text_stepper.line_start_idx + col From e79f170d30da6b97363ed8e25dd9d2ec593da57e Mon Sep 17 00:00:00 2001 From: zerone0x <39543393+zerone0x@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:04:07 +0000 Subject: [PATCH 126/170] fix: reject malformed brace globs Fixes #1690 Co-Authored-By: OpenAI Codex <noreply@openai.com> --- CHANGELOG.md | 1 + src/serena/util/text_utils.py | 12 ++++++++---- test/serena/test_text_utils.py | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a67618fb..edeb6bcd 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 - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks); previously only "\n" considered - Fix: Apply consistent line splitting across tools, uniformly applying the LSP splitting semantics; affects `search_text` used by `search_for_pattern` tool (reported in #1684) + - Fix: glob pattern expansion in `expand_braces` did not terminate with empty or unbalanced braces #1690 * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 8deb9856..e9aebf76 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -216,18 +216,22 @@ def expand_braces(pattern: str) -> list[str]: Handles multiple brace sets as well. """ patterns = [pattern] - while any("{" in p for p in patterns): + while any("{" in p or "}" in p for p in patterns): new_patterns = [] for p in patterns: - match = re.search(r"\{([^{}]+)\}", p) + match = re.search(r"\{([^{}]*)\}", p) if match: + options_expr = match.group(1) + if not options_expr: + raise ValueError(f"Invalid glob brace expression in {pattern!r}: empty braces are not allowed") + prefix = p[: match.start()] suffix = p[match.end() :] - options = match.group(1).split(",") + options = options_expr.split(",") for option in options: new_patterns.append(f"{prefix}{option}{suffix}") else: - new_patterns.append(p) + raise ValueError(f"Invalid glob brace expression in {pattern!r}: unmatched brace") patterns = new_patterns return patterns diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 936779fc..ce63450d 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -606,6 +606,21 @@ class TestExpandBraces: assert sorted(expand_braces(pattern)) == sorted(expected) + @pytest.mark.parametrize( + "pattern", + [ + "src/{}.py", + "src/{utils,models", + "src/utils,models}.py", + ], + ) + def test_expand_braces_rejects_malformed_braces(self, pattern): + """Malformed brace globs should fail instead of looping forever.""" + from serena.util.text_utils import expand_braces + + with pytest.raises(ValueError, match="Invalid glob brace expression"): + expand_braces(pattern) + class TestMultiFileContentReplacer: FILES = [ From 1d4c460dade3385999e36bda1b968196c902bbab Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 15 Jul 2026 20:14:29 +0200 Subject: [PATCH 127/170] JetBrains: Add version guard for find symbol with wildcard --- src/serena/jetbrains/jetbrains_plugin_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 22fa9c27..5c19784a 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -417,6 +417,8 @@ class JetBrainsPluginClient(ToStringMixin): :param include_location: whether to include symbol location information :param search_deps: whether to also search in dependencies """ + if "*" in name_path: + self._require_version_at_least(2023, 3, 1) request_data = { "namePath": name_path, "relativePath": relative_path, From 08b87bb03de848b5ba611f8d127ba19535c5ae31 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Wed, 15 Jul 2026 20:17:56 +0200 Subject: [PATCH 128/170] JetBrainsClient: Add version guards for inspections calls --- src/serena/jetbrains/jetbrains_plugin_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 5c19784a..570925f2 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -686,6 +686,7 @@ class JetBrainsPluginClient(ToStringMixin): :param start_line: optional start line to restrict the inspection range :param end_line: optional end line to restrict the inspection range """ + self._require_version_at_least(2023, 2, 14) request_data: dict[str, Any] = { "relativePath": relative_path, } @@ -710,6 +711,7 @@ class JetBrainsPluginClient(ToStringMixin): :param language: optional language to filter inspections by (e.g. "Java", "Python") :param group_path_contains: optional substring to filter inspection group paths """ + self._require_version_at_least(2023, 2, 14) request_data: dict[str, Any] = {} if language is not None: request_data["language"] = language From cb38f6f82aac111971e0bc96c542dbcd30eef946 Mon Sep 17 00:00:00 2001 From: AmirF194 <amirfathi.me@gmail.com> Date: Fri, 10 Jul 2026 18:51:08 -0600 Subject: [PATCH 129/170] fix(editing): delete last line of a file without a trailing newline delete_lines/replace_lines address the position one line past the last line (line == number of lines, col 0). get_index_from_line_col cannot resolve that position when the file has no trailing newline (no closing newline to count), so delete_text_between_positions raised InvalidTextLocationError and the delete crashed on a very common file shape. Clamp that past-EOF end position to end-of-file in delete_text_between_positions, mirroring the guard insert_text_at_position already has. A file with no trailing newline now deletes its last line identically to one with a trailing newline. Adds TextUtils unit tests and a CHANGELOG entry. --- CHANGELOG.md | 4 ++++ src/solidlsp/ls_utils.py | 16 +++++++++++++++- test/solidlsp/test_text_utils.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edeb6bcd..1f74d656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks); previously only "\n" considered - Fix: Apply consistent line splitting across tools, uniformly applying the LSP splitting semantics; affects `search_text` used by `search_for_pattern` tool (reported in #1684) + - Fix in `TextUtils` (used by editors): Deleting up to the end of the file, referencing the line one past + the end of the file (particularly for files with no newline at end of file) raised `InvalidTextLocationError` + instead of accepting the deletion (change in `TextUtils.delete_text_between_positions`, + which now accepts the end position similar to `insert_text_at_position`). - Fix: glob pattern expansion in `expand_braces` did not terminate with empty or unbalanced braces #1690 * CLI: diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index eafc3b37..d95723ad 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -237,7 +237,21 @@ class TextUtils: :return: a tuple containing the modified text and the deleted text """ del_start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col) - del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) + try: + del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) + except InvalidTextLocationError: + # Deleting through the final line addresses the position one line past the + # last line (line == number of lines, col 0). When the file has no trailing + # newline there is no closing newline to count, so get_index_from_line_col + # cannot resolve it; that position means "end of file". Clamp to len(text). + # (insert_text_at_position handles the same past-EOF position.) + text_stepper = TextStepper(text) + text_stepper.process_all() + num_lines_in_text = text_stepper.line + 1 + if end_line == num_lines_in_text and end_col == 0: + del_end_idx = len(text) + else: + raise deleted_text = text[del_start_idx:del_end_idx] new_text = text[:del_start_idx] + text[del_end_idx:] diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py index 062125c4..0d0fca42 100644 --- a/test/solidlsp/test_text_utils.py +++ b/test/solidlsp/test_text_utils.py @@ -1,4 +1,6 @@ -from solidlsp.ls_utils import TextUtils +import pytest + +from solidlsp.ls_utils import InvalidTextLocationError, TextUtils class TestTextUtils: @@ -48,3 +50,29 @@ class TestTextUtils: new_text, l, c = TextUtils.insert_text_at_position(self.TEXT, 4, 0, insertion) assert (l, c) == (4, len(insertion)) assert new_text == self.TEXT + "\n" + insertion + + def test_delete_text_deletes_last_line_without_trailing_newline(self) -> None: + """Deleting the final line must work whether or not the file ends in a newline. + + delete_lines(k, N-1) addresses the position one line past the last line + (line N, col 0). With no trailing newline there is no closing newline to + count, so get_index_from_line_col cannot resolve it; the delete must still + remove the last line instead of raising InvalidTextLocationError. + """ + # File with 3 lines, no trailing newline: read_file (splitlines) shows 0='a',1='b',2='c'. + text = "a\nb\nc" + new_text, deleted = TextUtils.delete_text_between_positions(text, 2, 0, 3, 0) + assert new_text == "a\nb\n" + assert deleted == "c" + + def test_delete_text_last_line_matches_trailing_newline_variant(self) -> None: + """Deleting the last line yields the same result with or without a trailing newline.""" + without_nl, _ = TextUtils.delete_text_between_positions("a\nb\nc", 2, 0, 3, 0) + with_nl, _ = TextUtils.delete_text_between_positions("a\nb\nc\n", 2, 0, 3, 0) + assert without_nl == with_nl == "a\nb\n" + + def test_delete_text_still_raises_for_out_of_range_end(self) -> None: + """A genuinely out-of-range end position (beyond one-past-EOF) still raises.""" + with pytest.raises(InvalidTextLocationError): + # end_line = 5 is well past the one-line-past-EOF position (3) for a 3-line file. + TextUtils.delete_text_between_positions("a\nb\nc", 0, 0, 5, 0) From 08bc0615f5a712636d53a96eb0a303adc43fd09f Mon Sep 17 00:00:00 2001 From: Ehsan Barkhordar <realbarkhordar@gmail.com> Date: Wed, 15 Jul 2026 22:11:09 +0000 Subject: [PATCH 130/170] fix(tools): make read_file report the line numbers the editing tools use read_file determined line indices via Python's splitlines, which breaks on \f, \v, \x1c-\x1e, \x85, U+2028 and U+2029 in addition to the LSP-compliant line breaks counted by delete_lines/replace_lines. In a file containing any of those characters the indices reported by read_file referred to different physical lines than the editing tools operate on, so an edit made after the required read_file could silently hit the wrong line. Use TextUtils.split_lines, as already applied to search_text and MatchedConsecutiveLines.from_file_contents. Files whose line breaks are all LSP-compliant are decomposed exactly as before. --- src/serena/tools/file_tools.py | 5 ++- test/serena/test_file_tools.py | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 test/serena/test_file_tools.py diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 868ef126..5bf224dc 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -21,6 +21,7 @@ from serena.util.text_utils import ( expand_braces, glob_match, ) +from solidlsp.ls_utils import TextUtils class ReadFileTool(Tool): @@ -42,8 +43,10 @@ class ReadFileTool(Tool): """ self.project.validate_relative_path(relative_path, require_not_ignored=True) + # read lines, using the same (LSP-compliant) notion of line breaks as the line-based editing tools result = self.project.read_file(relative_path) - result_lines = result.splitlines() + result_lines = TextUtils.split_lines(result) + if end_line is None: result_lines = result_lines[start_line:] else: diff --git a/test/serena/test_file_tools.py b/test/serena/test_file_tools.py new file mode 100644 index 00000000..a2e056e2 --- /dev/null +++ b/test/serena/test_file_tools.py @@ -0,0 +1,77 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from serena.config.serena_config import SerenaConfig +from serena.constants import DEFAULT_SOURCE_FILE_ENCODING +from serena.project import Project +from serena.tools import ReadFileTool +from solidlsp.ls_utils import TextUtils + + +@pytest.fixture +def read_file_tool(tmp_path: Path) -> ReadFileTool: + project = Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False)) + agent = MagicMock() + agent.get_active_project_or_raise.return_value = project + tool = ReadFileTool(agent) + # bypass the length limit, which would otherwise depend on the agent configuration + tool._limit_length = lambda result, max_answer_chars: result # type: ignore[method-assign] + return tool + + +def _deleted_by_delete_lines(content: str, line: int) -> str: + """ + :return: the text that `delete_lines(line, line)` removes from the given content, which is + computed via the same primitive the tool uses (`code_editor.delete_lines` deletes the text + between (line, 0) and (line + 1, 0)). + """ + _, deleted_text = TextUtils.delete_text_between_positions(content, start_line=line, start_col=0, end_line=line + 1, end_col=0) + return deleted_text + + +class TestReadFileToolLineNumbering: + """ + `delete_lines`/`replace_lines` require the same range of lines to have been read via `read_file` + beforehand, so the line indices reported by `read_file` must be the ones the editing tools apply to. + """ + + @pytest.mark.parametrize( + "content", + [ + "line0\nline1\nline2\n", + "line0\nline1\nline2", + "line0\r\nline1\r\nline2\r\n", + "line0\rline1\rline2\r", + "line0\n\nline2\n", + # separators which Python's `splitlines` breaks on but which are not line breaks according to the LSP + "line0\n\x0cline1\nline2\n", + "line0\n\x0bline1\nline2\n", + "line0\n\x1cline1\nline2\n", + "line0\n\x85line1\nline2\n", + "line0\n\u2028line1\nline2\n", + ], + ) + def test_read_file_lines_match_lines_targeted_by_delete_lines(self, read_file_tool: ReadFileTool, tmp_path: Path, content: str) -> None: + (tmp_path / "file.txt").write_text(content, newline="", encoding=DEFAULT_SOURCE_FILE_ENCODING) + + for line in range(len(TextUtils.split_lines(content.rstrip("\n")))): + read_line = read_file_tool.apply("file.txt", start_line=line, end_line=line) + assert read_line == _deleted_by_delete_lines(content, line).rstrip("\r\n") + + def test_form_feed_does_not_shift_reported_line_indices(self, read_file_tool: ReadFileTool, tmp_path: Path) -> None: + # a form feed is a page-break convention within a line, not a line break + (tmp_path / "file.txt").write_text("line0\n\x0cline1\nline2\n", newline="", encoding=DEFAULT_SOURCE_FILE_ENCODING) + + assert read_file_tool.apply("file.txt") == "line0\n\x0cline1\nline2\n" + assert read_file_tool.apply("file.txt", start_line=1, end_line=1) == "\x0cline1" + + @pytest.mark.parametrize( + "content", + ["", "line0", "line0\n", "line0\nline1", "line0\nline1\n", "line0\n\n", "\n", "line0\r\nline1\r\n", "line0\rline1\r"], + ) + def test_read_full_file_recovers_rejoined_lines(self, read_file_tool: ReadFileTool, tmp_path: Path, content: str) -> None: + (tmp_path / "file.txt").write_text(content, newline="", encoding=DEFAULT_SOURCE_FILE_ENCODING) + + assert read_file_tool.apply("file.txt") == "\n".join(TextUtils.split_lines(content)) From 41859b5fe1e10bb0f3de481efde8c61c5987a74e Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 16 Jul 2026 11:02:58 +0200 Subject: [PATCH 131/170] Add news item for v1.6.0 release --- news/20260716.html | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 news/20260716.html diff --git a/news/20260716.html b/news/20260716.html new file mode 100644 index 00000000..129820f5 --- /dev/null +++ b/news/20260716.html @@ -0,0 +1,45 @@ +<div class="news-item"> + <h3>Serena v1.6.0</h3> + <p class="date">July 16, 2026</p> + <p> + A lot has happened since the last news item for v1.3.0. Here are the highlights: + </p> + <p> + <b>Extended Memory System.</b> + Memories can now reference each other via the <code>mem:<name></code> convention (renames propagate automatically), + and a new <code>serena memories</code> CLI command group supports listing, reading, writing and integrity checking. + See the <a target="_blank" href="https://oraios.github.io/serena/02-usage/045_memories.html">memories documentation</a> for details. + </p> + <p> + <b>Trusted Projects & Activation Commands.</b> + The new global setting <code>trusted_project_path_patterns</code> introduces the notion of trusted projects, + which may define an <code>activation_command</code> that is run before the language backend initialises + (e.g. to generate sources a language server needs) as well as language server-specific settings. + </p> + <p> + <b>Expanded Language Support.</b> + New languages for the LSP backend: GDScript (Godot Engine), CUE, QML, and LaTeX (experimental); + PHPantom was added as an alternative PHP language server. + </p> + <p> + <b>New Tools & Configurability.</b> + The new <code>replace_in_files</code> tool applies pattern replacements across multiple files in one call. + Language server launching and indexing became more configurable, + and the JetBrains backend can now auto-launch IDE instances upon project activation + via <code>jetbrains_launch_command</code>. + </p> + <p> + <b>Robustness & Fixes.</b> + A multitude of fixes and improvements across the board, including many language servers + (Java, TypeScript, Svelte, C#, Julia, Perl, and more), the dashboard, + the CLI (git worktree handling), and general token efficiency. + </p> + <p> + See our <a target="_blank" href="https://github.com/oraios/serena/blob/main/CHANGELOG.md">change log</a> for the full list of changes. + </p> + <p> + <b>Update Now.</b> + If you are using the uv tool installation of Serena, upgrade as follows:<br> + uv tool upgrade serena-agent + </p> +</div> From 93b9544ea9def8e93cb6a90f8ea67befe3c8fee4 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 16 Jul 2026 11:03:04 +0200 Subject: [PATCH 132/170] Release v1.6.0 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- src/serena/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f74d656..a001291c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +# v1.6.0 (2026-07-16) + * General: - Speed up MCP startup when auto-creating projects at startup (e.g. using `--project-from-cwd`) by determining the project's languages in a background thread #1683 diff --git a/pyproject.toml b/pyproject.toml index 4b1020f5..1b2bcc50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "serena-agent" -version = "1.5.4.dev0" +version = "1.6.0" 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" diff --git a/src/serena/__init__.py b/src/serena/__init__.py index eaa65f49..6d15b384 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.5.4.dev0" +__version__ = "1.6.0" import logging diff --git a/uv.lock b/uv.lock index d82e273a..8fef8ca0 100644 --- a/uv.lock +++ b/uv.lock @@ -2803,7 +2803,7 @@ wheels = [ [[package]] name = "serena-agent" -version = "1.5.4.dev0" +version = "1.6.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, From bfcab428d2fcc2bfcc3c47dd692cbbdb1d5a080a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 16 Jul 2026 11:03:05 +0200 Subject: [PATCH 133/170] Set version to v1.6.1.dev0 --- pyproject.toml | 2 +- src/serena/__init__.py | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1b2bcc50..fdbc6521 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "serena-agent" -version = "1.6.0" +version = "1.6.1.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" diff --git a/src/serena/__init__.py b/src/serena/__init__.py index 6d15b384..9b317970 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.6.0" +__version__ = "1.6.1.dev0" import logging diff --git a/uv.lock b/uv.lock index 8fef8ca0..64d0ed42 100644 --- a/uv.lock +++ b/uv.lock @@ -2803,7 +2803,7 @@ wheels = [ [[package]] name = "serena-agent" -version = "1.6.0" +version = "1.6.1.dev0" source = { editable = "." } dependencies = [ { name = "anthropic" }, From 063f872a0110748cc5fb6c322cb0bf6ec8a783af Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 16 Jul 2026 15:30:17 +0200 Subject: [PATCH 134/170] Remove unused type ignore --- test/serena/test_file_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/serena/test_file_tools.py b/test/serena/test_file_tools.py index a2e056e2..24c1649d 100644 --- a/test/serena/test_file_tools.py +++ b/test/serena/test_file_tools.py @@ -17,7 +17,7 @@ def read_file_tool(tmp_path: Path) -> ReadFileTool: agent.get_active_project_or_raise.return_value = project tool = ReadFileTool(agent) # bypass the length limit, which would otherwise depend on the agent configuration - tool._limit_length = lambda result, max_answer_chars: result # type: ignore[method-assign] + tool._limit_length = lambda result, max_answer_chars: result return tool From f1486f7d60982b9b4e77d376f35ca60394262f1d Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 16 Jul 2026 15:04:48 +0200 Subject: [PATCH 135/170] Add JetBrainsPluginClient.read_file --- src/serena/jetbrains/jetbrains_plugin_client.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index d72ca234..db69de9b 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -734,6 +734,16 @@ class JetBrainsPluginClient(ToStringMixin): request_data["groupPathContains"] = group_path_contains return cast(jb.ListInspectionsResponse, self._make_request("POST", "/listInspections", request_data)) + def read_file(self, relative_path: str) -> str: + self._require_version_at_least(2023, 3, 3) + request_data = { + "relativePath": relative_path, + } + response = self._make_request("POST", "/readFile", request_data) + if "content" not in response: + raise PluginServerError(f"Unexpected response from readFile: {response}") + return response["content"] + def close(self) -> None: self._session.close() From f3cf5d3f8d5d857c7cae6bbf62685576ddf70e61 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 16 Jul 2026 15:28:29 +0200 Subject: [PATCH 136/170] JetBrains: Allow external files from dependencies (e.g. "<ext:FileUtil.class|472e0a13>") to be - read via `ReadFileTool` - searched via `SearchForPatternTool` when using plugin version 2023.3.3+ (yet unreleased) This is enabled via JetBrainsFileProxy (which reads the file through the IDE) --- CHANGELOG.md | 6 +++ src/serena/jetbrains/jetbrains_types.py | 8 ++++ src/serena/project.py | 60 +++++++++++++++---------- src/serena/tools/file_tools.py | 4 -- src/serena/util/file_proxy.py | 40 +++++++++++++++++ 5 files changed, 91 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a001291c..b85f815a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* JetBrains: + - Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be + - read via `ReadFileTool` + - searched via `SearchForPatternTool` + when using plugin version 2023.3.3+ + # v1.6.0 (2026-07-16) * General: diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index 65add4fb..89bdae76 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -6,6 +6,14 @@ Prefix used for in relative paths of symbols that are from external libraries (i """ +def is_external_path(relative_path: str): + """ + :param relative_path: a relative path (e.g., from a symbol's `relative_path` field) + :return: whether the path is an external path (i.e., from a library, not the user's codebase) + """ + return relative_path.startswith(JB_EXTERNAL_FILE_PREFIX) + + class PluginStatusDTO(TypedDict): project_root: str plugin_version: str diff --git a/src/serena/project.py b/src/serena/project.py index 51b27ce3..9cb183d9 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -16,12 +16,11 @@ from serena.config.serena_config import ( ) from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.memories.memory_manager import MemoryManager -from serena.util.file_proxy import FileCollection +from serena.util.file_proxy import FileCollection, FileProxy from serena.util.file_system import GitignoreParser, match_path, scan_directory from serena.util.text_utils import MatchedConsecutiveLines, search_files from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from solidlsp.ls_utils import FileUtils if TYPE_CHECKING: from serena.agent import SerenaAgent @@ -169,13 +168,13 @@ class Project(ToStringMixin): def read_file(self, relative_path: str) -> str: """ - Reads a file relative to the project root. + Reads a project file. - :param relative_path: the path to the file relative to the project root + :param relative_path: the path to the file relative to the project root or an external + path token like "<ext:FileUtil.class|472e0a13>" :return: the content of the file """ - abs_path = Path(self.project_root) / relative_path - return FileUtils.read_file(str(abs_path), self.project_config.encoding) + return FileProxy.from_project_relative_path(self, relative_path).get_contents() @property def _ignore_spec(self) -> pathspec.PathSpec: @@ -316,6 +315,9 @@ class Project(ToStringMixin): :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 """ + if FileProxy.is_external_path(relative_path): + return + if not self.is_path_in_project(relative_path): raise ValueError(f"{relative_path=} points outside the project root ({self.project_root})") @@ -360,6 +362,34 @@ class Project(ToStringMixin): ) return rel_file_paths + def _create_file_collection(self, relative_path: str, code_files_only: bool) -> FileCollection: + if FileProxy.is_external_path(relative_path): + # single external path: create appropriate proxy + file_collection = FileCollection([FileProxy.from_project_relative_path(self, relative_path)]) + else: + # path is a local project path + abs_path = os.path.join(self.project_root, relative_path) + if not os.path.exists(abs_path): + raise FileNotFoundError(f"Relative path {relative_path} does not exist.") + + if code_files_only: + relative_file_paths = self.gather_source_files(relative_path=relative_path) + file_collection = FileCollection.from_local_project_paths(relative_file_paths, self) + else: + abs_path = os.path.join(self.project_root, relative_path) + if os.path.isfile(abs_path): + rel_paths_to_search = [relative_path] + else: + _dirs, rel_paths_to_search = scan_directory( + path=abs_path, + recursive=True, + is_ignored_dir=self.is_ignored_path, + is_ignored_file=self.is_ignored_path, + relative_to=self.project_root, + ) + file_collection = FileCollection.from_local_project_paths(rel_paths_to_search, self) + return file_collection + def search_project_files_for_pattern( self, pattern: str, @@ -383,23 +413,7 @@ class Project(ToStringMixin): :param multiline: Whether to compile the regex with the DOTALL flag (``.`` matches newlines). :return: List of matched consecutive lines with context """ - if code_files_only: - relative_file_paths = self.gather_source_files(relative_path=relative_path) - file_collection = FileCollection.from_local_project_paths(relative_file_paths, self) - else: - abs_path = os.path.join(self.project_root, relative_path) - if os.path.isfile(abs_path): - rel_paths_to_search = [relative_path] - else: - _dirs, rel_paths_to_search = scan_directory( - path=abs_path, - recursive=True, - is_ignored_dir=self.is_ignored_path, - is_ignored_file=self.is_ignored_path, - relative_to=self.project_root, - ) - file_collection = FileCollection.from_local_project_paths(rel_paths_to_search, self) - + file_collection = self._create_file_collection(relative_path, code_files_only) return search_files( file_collection, pattern, diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 5bf224dc..65ecea7b 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -593,10 +593,6 @@ class SearchForPatternTool(Tool): if relative_path: self.project.validate_relative_path(relative_path, require_not_ignored=True) - abs_path = os.path.join(self.get_project_root(), relative_path) - if not os.path.exists(abs_path): - raise FileNotFoundError(f"Relative path {relative_path} does not exist.") - matches = self.project.search_project_files_for_pattern( pattern=substring_pattern, relative_path=relative_path, diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py index 53685dc8..1362aa16 100644 --- a/src/serena/util/file_proxy.py +++ b/src/serena/util/file_proxy.py @@ -4,6 +4,8 @@ from abc import ABC, abstractmethod from collections.abc import Iterator from typing import TYPE_CHECKING, Self +from serena.jetbrains import jetbrains_types as jb + if TYPE_CHECKING: from serena.project import Project @@ -25,6 +27,21 @@ class FileProxy(ABC): :return: whether the proxy supports glob filtering based on its relative path """ + @staticmethod + def is_external_path(relative_path: str) -> bool: + """ + :return: whether the given relative path is an encoded external path (not a local project file) + """ + # This is intended to be extended once we also support external paths in other backends + return jb.is_external_path(relative_path) + + @classmethod + def from_project_relative_path(cls, project: "Project", relative_path: str) -> "FileProxy": + if cls.is_external_path(relative_path): + if project.language_backend.is_jetbrains(): + return JetBrainsFileProxy(relative_path, project) + return LocalProjectFileProxy(relative_path, project) + class LocalProjectFileProxy(FileProxy): def __init__(self, relative_path: str, project: "Project"): @@ -43,6 +60,29 @@ class LocalProjectFileProxy(FileProxy): return True +class JetBrainsFileProxy(FileProxy): + """ + Retrieves the contents of a file from the JetBrains plugin via the plugin client, given its relative path, + which may be an external path (e.g., "<ext:FileUtil.class|472e0a13>") + """ + + def __init__(self, relative_path: str, project: "Project"): + self._relative_path = relative_path + self._project = project + + def get_contents(self) -> str: + from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient + + client = JetBrainsPluginClient.from_project(self._project) + return client.read_file(self._relative_path) + + def get_relative_path(self) -> str: + return self._relative_path + + def is_glob_supported(self): + return False + + class FileCollection: def __init__(self, file_proxies: list[FileProxy]): self._file_proxies = file_proxies From f3754c922d88b9aab2dfb7c919634afbc060dda6 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 17 Jul 2026 05:03:52 +0200 Subject: [PATCH 137/170] Improve documentation of line ending handling --- CHANGELOG.md | 3 ++- src/solidlsp/ls_utils.py | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a001291c..e8e3ac1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,8 @@ Status of the `main` branch. Changes prior to the next official version change w - During project creation, language composition percentages are now computed relative to the total number of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage computation. - - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks); previously only "\n" considered + - Consider all LSP-compliant line endings ("\n", "\r\n" and "\r") in `TextUtils`, noting that + only "\n" appears in files read by `FileUtils.read_file` (Serena's default reading mechanism) - Fix: Apply consistent line splitting across tools, uniformly applying the LSP splitting semantics; affects `search_text` used by `search_for_pattern` tool (reported in #1684) - Fix in `TextUtils` (used by editors): Deleting up to the end of the file, referencing the line one past diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index d95723ad..b87355bd 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -32,6 +32,13 @@ class InvalidTextLocationError(Exception): class TextStepper: + r""" + A utility class for stepping through a text string line by line, keeping track of the current line and column numbers. + + It handles the newline sequences "\n", "\r\n", and "\r" as defined by the Language Server Protocol (LSP). + However, note that files read through `FileUtils.read_file` will contain only "\n" (LF). + """ + def __init__(self, chars: str): self._chars = chars self._len = len(chars) From e8a550763b7e28ff434f042806bd7f13d1e53eae Mon Sep 17 00:00:00 2001 From: Ehsan <realbarkhordar@gmail.com> Date: Fri, 17 Jul 2026 06:11:26 +0300 Subject: [PATCH 138/170] fix: normalize line endings in read_file's charset_normalizer fallback (#1706) FileUtils.read_file has two return paths with different newline semantics. The primary path, open(file_path, encoding=encoding).read(), uses the default newline=None and therefore applies universal newlines, so CR never reaches callers. The charset_normalizer fallback decodes the raw bytes instead (match.raw.decode(match.encoding)), which performs no newline translation, so any file that fails to decode with the project's configured encoding re-enters Serena carrying its CR characters. Normalize to LF at the fallback's return, matching the primary path. --- CHANGELOG.md | 8 ++++ src/solidlsp/ls_utils.py | 8 +++- test/solidlsp/util/test_ls_utils.py | 64 +++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e3ac1d..4f5d9d95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* General: + - Fix: `FileUtils.read_file`'s `charset_normalizer` fallback (used when a file cannot be decoded with + the project's configured `encoding`) decoded the raw bytes directly and therefore skipped the + universal-newline translation that the primary read path applies. CR characters from disk thus + reached Serena's in-memory file contents, where the rest of the code assumes LF-normalized text and + the `line_ending` setting is meant to be the single point of line-ending translation on write. The + fallback now normalizes line endings to LF, consistently with the primary path. + # v1.6.0 (2026-07-16) * General: diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index b87355bd..7ff1bb71 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -387,6 +387,9 @@ class FileUtils: Reads the file at the given path using the given encoding and returns the contents as a string. If decoding fails, tries to detect the encoding using charset_normalizer. + Line endings are normalized to LF (universal newlines), irrespective of the encoding + used to decode the file. + Raises FileNotFoundError if the file does not exist. """ if not os.path.exists(file_path): @@ -403,7 +406,10 @@ class FileUtils: log.warning( f"Could not decode {file_path} with encoding='{encoding}'; using best match '{match.encoding}' instead", ) - return match.raw.decode(match.encoding) + # Decoding the raw bytes bypasses the universal-newline translation that the + # open() call above applies, so normalize explicitly to keep both paths equivalent. + decoded = match.raw.decode(match.encoding) + return decoded.replace("\r\n", "\n").replace("\r", "\n") raise ude except Exception as exc: log.error(f"Failed to read '{file_path}' with encoding '{encoding}': {exc}") diff --git a/test/solidlsp/util/test_ls_utils.py b/test/solidlsp/util/test_ls_utils.py index cad603c3..a4876645 100644 --- a/test/solidlsp/util/test_ls_utils.py +++ b/test/solidlsp/util/test_ls_utils.py @@ -4,6 +4,8 @@ import hashlib from pathlib import Path from unittest.mock import patch +import pytest + from solidlsp.ls_utils import FileUtils @@ -40,3 +42,65 @@ def test_download_file_verified_writes_decoded_response_body(tmp_path: Path) -> ) assert target_path.read_bytes() == payload + + +# A file that cannot be decoded with the project encoding, forcing read_file's +# charset_normalizer fallback. The accented characters make the bytes invalid UTF-8, +# and the content is long enough for encoding detection to be reliable. +_CP1252_LINES = [ + "# -*- coding: cp1252 -*-", + "# Author: José Fernández", + "# Copyright (c) 2019 Müller & Söhne GmbH.", + "", + "import os", + "", + "", + "class ConfiguracionBasica:", + ' """Clase de configuración para el módulo de facturación."""', + "", + " def __init__(self, nombre, valor=None):", + " self.nombre = nombre", + " self.valor = valor", + "", + " def describir(self):", + ' return f"{self.nombre}: {self.valor}"', +] + + +def test_read_file_fallback_normalizes_crlf(tmp_path: Path) -> None: + """The charset_normalizer fallback should apply universal newlines, just like the primary path.""" + file_path = tmp_path / "config_cp1252.py" + file_path.write_bytes(("\r\n".join(_CP1252_LINES) + "\r\n").encode("cp1252")) + + # guard against a vacuous test: the fixture must actually force the fallback + with pytest.raises(UnicodeDecodeError): + file_path.read_text(encoding="utf-8") + + content = FileUtils.read_file(str(file_path), "utf-8") + + assert "José Fernández" in content, "fallback should decode the file as cp1252" + assert "\r" not in content + assert content.splitlines() == _CP1252_LINES + + +def test_read_file_fallback_normalizes_lone_cr(tmp_path: Path) -> None: + """Old-style lone CR separators should be normalized by the fallback as well.""" + file_path = tmp_path / "lone_cr_cp1252.py" + file_path.write_bytes(("\r".join(_CP1252_LINES) + "\r").encode("cp1252")) + + content = FileUtils.read_file(str(file_path), "utf-8") + + assert "\r" not in content + assert content.splitlines() == _CP1252_LINES + + +def test_read_file_primary_path_normalizes_crlf(tmp_path: Path) -> None: + """Control: the primary open() path already normalizes; both paths must agree.""" + lines = ["import os", "", "def f():", " return 1"] + file_path = tmp_path / "config_utf8.py" + file_path.write_bytes(("\r\n".join(lines) + "\r\n").encode("utf-8")) + + content = FileUtils.read_file(str(file_path), "utf-8") + + assert "\r" not in content + assert content.splitlines() == lines From c7ee15c0165e7b51ef9e421b3102fdda0702ed62 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 17 Jul 2026 06:05:50 +0200 Subject: [PATCH 139/170] Fix f-string --- src/solidlsp/ls_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 7ff1bb71..f00e1f04 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -210,7 +210,7 @@ class TextUtils: text_stepper = TextStepper(text) while text_stepper.line < line: if not text_stepper.step_line(): - raise InvalidTextLocationError("{line=}, {col=}") + raise InvalidTextLocationError(f"{line=}, {col=}") return text_stepper.line_start_idx + col From 844535f6236a549eb1b6f29320c5ecb30b7ca9ef Mon Sep 17 00:00:00 2001 From: Ehsan Barkhordar <realbarkhordar@gmail.com> Date: Fri, 17 Jul 2026 07:27:22 +0000 Subject: [PATCH 140/170] fix(search): do not mark the following line as matched when a match ends at a line break `search_text` mapped the match's exclusive end index to a line number via `get_line_from_index`, which resolves an index at a line start to the following line. A match ending with a newline was therefore attributed one line too many, and `context_lines_after` was shifted by the same amount. --- CHANGELOG.md | 6 +++++ src/serena/util/text_utils.py | 4 +++ test/serena/test_text_utils.py | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf8cfa2..ef8e53e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ Status of the `main` branch. Changes prior to the next official version change w the `line_ending` setting is meant to be the single point of line-ending translation on write. The fallback now normalizes line endings to LF, consistently with the primary path. +* Tools: + - Fix: `search_for_pattern` marked one line too many as matched whenever a match ended with a line + break, because the match's exclusive end index was mapped to a line number directly and therefore + resolved to the start of the following line. The line the match ends on is now determined correctly, + which also keeps `context_lines_after` aligned. + * JetBrains: - Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be - read via `ReadFileTool` diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index e9aebf76..4f8f10cc 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -187,6 +187,10 @@ def search_text( # Find the line numbers for the start and end positions start_line_num = TextUtils.get_line_from_index(content, start_pos) end_line_num = TextUtils.get_line_from_index(content, end_pos) + if end_line_num > start_line_num and TextUtils.get_line_col_from_index(content, end_pos)[1] == 0: + # `end_pos` is exclusive, so if it is at the start of a line, the match ends with the + # preceding line's newline and does not extend into the line that `end_pos` points to + end_line_num -= 1 # Calculate the range of lines to include in the context context_start = max(0, start_line_num - context_lines_before) diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index ce63450d..0eb86f3c 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -103,6 +103,55 @@ class TestSearchText: assert "return a * c" in first_match.lines[1].line_content assert "elif b > a:" in first_match.lines[2].line_content + @pytest.mark.parametrize( + ("pattern", "expected_matched_lines"), + [ + # matches ending with a newline: the trailing newline terminates the last matched line + # and must not pull in the line that follows + (r"alpha\n", [0]), + (r"alpha\nbeta\n", [0, 1]), + (r"gamma\n", [2]), + # controls: matches ending mid-line were always correct + (r"alpha", [0]), + (r"beta", [1]), + (r"alpha\nbeta", [0, 1]), + ], + ) + def test_search_text_match_ending_at_line_break(self, pattern: str, expected_matched_lines: list[int]): + """The lines marked as matched are exactly the lines the matched text occupies.""" + content = "alpha\nbeta\ngamma\n" + + matches = search_text(pattern, content=content) + + assert len(matches) == 1 + matched_lines = [line.line_number for line in matches[0].lines if line.match_type == LineType.MATCH] + assert matched_lines == expected_matched_lines + assert matches[0].num_matched_lines == len(expected_matched_lines) + + @pytest.mark.parametrize( + ("pattern", "expected_matched_lines"), + [ + (r"alpha\r\n", [0]), + (r"alpha\r", [0]), + (r"alpha\r\nbeta\r\n", [0, 1]), + (r"alpha", [0]), + ], + ) + def test_search_text_match_ending_at_line_break_crlf(self, pattern: str, expected_matched_lines: list[int]): + """As above for CRLF content, where the end index can fall inside the two-character line break. + + The content is passed directly rather than read from a file, since the file read paths translate + line endings and CR would not reach this function. + """ + content = "alpha\r\nbeta\r\ngamma\r\n" + + matches = search_text(pattern, content=content) + + assert len(matches) == 1 + matched_lines = [line.line_number for line in matches[0].lines if line.match_type == LineType.MATCH] + assert matched_lines == expected_matched_lines + assert matches[0].num_matched_lines == len(expected_matched_lines) + def test_search_text_with_multiline_match(self): """Test searching with multiline pattern matching.""" content = """ From 04445da438a3323a7460752749805cfb6ef92f46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:18:10 +0000 Subject: [PATCH 141/170] Bump mcp from 1.27.0 to 1.28.1 in the uv group across 1 directory --- updated-dependencies: - dependency-name: mcp dependency-version: 1.28.1 dependency-type: direct:production dependency-group: uv ... Signed-off-by: dependabot[bot] <support@github.com> --- pyproject.toml | 2 +- uv.lock | 42 ++++++++++++++++++++++++------------------ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fdbc6521..4433faaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "requests==2.33.0", "overrides==7.7.0", "python-dotenv==1.2.2", - "mcp==1.27.0", + "mcp==1.28.1", "flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert) "sensai-utils==1.5.0", "pydantic==2.12.5", diff --git a/uv.lock b/uv.lock index 64d0ed42..86e307b1 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.15" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", "python_full_version == '3.12.*'", "python_full_version < '3.12'", ] @@ -963,7 +966,10 @@ name = "ipython" version = "9.12.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform != 'win32'", "python_full_version == '3.12.*'", ] dependencies = [ @@ -1360,7 +1366,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.0" +version = "1.28.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1378,9 +1384,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] @@ -1634,7 +1640,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -2115,7 +2121,7 @@ name = "pyobjc-framework-cocoa" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } wheels = [ @@ -2132,8 +2138,8 @@ name = "pyobjc-framework-quartz" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } wheels = [ @@ -2150,8 +2156,8 @@ name = "pyobjc-framework-security" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/aa/796e09a3e3d5cee32ebeebb7dcf421b48ea86e28c387924608a05e3f668b/pyobjc_framework_security-12.1.tar.gz", hash = "sha256:7fecb982bd2f7c4354513faf90ba4c53c190b7e88167984c2d0da99741de6da9", size = 168044, upload-time = "2025-11-14T10:22:06.334Z" } wheels = [ @@ -2168,8 +2174,8 @@ name = "pyobjc-framework-uniformtypeidentifiers" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/65/b8/dd9d2a94509a6c16d965a7b0155e78edf520056313a80f0cd352413f0d0b/pyobjc_framework_uniformtypeidentifiers-12.1.tar.gz", hash = "sha256:64510a6df78336579e9c39b873cfcd03371c4b4be2cec8af75a8a3d07dff607d", size = 17030, upload-time = "2025-11-14T10:23:02.222Z" } wheels = [ @@ -2181,8 +2187,8 @@ name = "pyobjc-framework-webkit" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, + { name = "pyobjc-framework-cocoa", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/14/10/110a50e8e6670765d25190ca7f7bfeecc47ec4a8c018cb928f4f82c56e04/pyobjc_framework_webkit-12.1.tar.gz", hash = "sha256:97a54dd05ab5266bd4f614e41add517ae62cdd5a30328eabb06792474b37d82a", size = 284531, upload-time = "2025-11-14T10:23:40.287Z" } wheels = [ @@ -2284,7 +2290,7 @@ name = "python-xlib" version = "0.33" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "six", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } wheels = [ @@ -2443,7 +2449,7 @@ name = "qtpy" version = "2.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/70/01/392eba83c8e47b946b929d7c46e0f04b35e9671f8bb6fc36b6f7945b4de8/qtpy-2.4.3.tar.gz", hash = "sha256:db744f7832e6d3da90568ba6ccbca3ee2b3b4a890c3d6fbbc63142f6e4cdf5bb", size = 66982, upload-time = "2025-02-11T15:09:25.759Z" } wheels = [ @@ -2889,7 +2895,7 @@ requires-dist = [ { name = "joblib", specifier = "==1.5.1" }, { name = "jupyter-book", marker = "extra == 'dev'", specifier = "==1.0.4.post1" }, { name = "lsprotocol", specifier = "==2025.0.0" }, - { name = "mcp", specifier = "==1.27.0" }, + { name = "mcp", specifier = "==1.28.1" }, { name = "oslex", specifier = "==2.0.0" }, { name = "overrides", specifier = "==7.7.0" }, { name = "pathspec", specifier = "==0.12.1" }, From 1940fc6d4311691e695661e8c5c98d9aeb36de8d Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 17 Jul 2026 13:35:01 +0200 Subject: [PATCH 142/170] JetBrainsCodeEditor: Use FileProxy to support reading of external files This adds support for external files to JetBrainsFindDeclarationTool CodeEditor: Guard use of edited_file_context against use with ext. files --- CHANGELOG.md | 1 + src/serena/code_editor.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8e53e4..4b9ef8ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be - read via `ReadFileTool` - searched via `SearchForPatternTool` + - used in `JetBrainsFindDeclarationTool` when using plugin version 2023.3.3+ # v1.6.0 (2026-07-16) diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index fed0297f..c4894a08 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -13,6 +13,7 @@ from solidlsp.ls import LSPFileBuffer from solidlsp.ls_utils import PathUtils, TextUtils from .project import Project +from .util.file_proxy import FileProxy log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) @@ -78,6 +79,8 @@ class CodeEditor(Generic[TSymbol], ABC): """ Context manager for editing a file. """ + if FileProxy.is_external_path(relative_path): + raise ValueError(f"Cannot edit external file: {relative_path}") with self._open_file_context(relative_path) as edited_file: yield edited_file # save the file @@ -400,8 +403,7 @@ class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): super().__init__(relative_path) path = os.path.join(project.project_root, relative_path) log.info("Editing file: %s", path) - with open(path, encoding=project.project_config.encoding) as f: - self._content = f.read() + self._content = FileProxy.from_project_relative_path(project, relative_path).get_contents() def get_contents(self) -> str: return self._content From 736e4f182df99cd04e79f1b6fed52e235b0173bf Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 17 Jul 2026 15:51:14 +0200 Subject: [PATCH 143/170] Add mcp version change to changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9ef8ed..e805d7d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ Status of the `main` branch. Changes prior to the next official version change w - used in `JetBrainsFindDeclarationTool` when using plugin version 2023.3.3+ +* Dependencies: + - Bump mcp from 1.27.0 to 1.28.1 + # v1.6.0 (2026-07-16) * General: From c379c30af4630b90b06930e3222e5116f7bf83a4 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Thu, 16 Jul 2026 20:15:04 +0200 Subject: [PATCH 144/170] Add heuristic to remove superfluous empty lines when deleting a symbol #1697 --- CHANGELOG.md | 1 + src/serena/code_editor.py | 24 +++++++++++++- src/solidlsp/ls_utils.py | 19 +++++++++-- test/serena/test_symbol_editing.py | 52 +++++++++++++++++++++++++++--- test/solidlsp/test_text_utils.py | 9 +++++- 5 files changed, 97 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e805d7d5..ea53bb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Status of the `main` branch. Changes prior to the next official version change w break, because the match's exclusive end index was mapped to a line number directly and therefore resolved to the start of the following line. The line the match ends on is now determined correctly, which also keeps `context_lines_after` aligned. + - `safe_delete`: Add heuristic to delete superfluous empty lines after a deletion * JetBrains: - Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index c4894a08..7652de09 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -10,7 +10,7 @@ from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol from solidlsp import SolidLanguageServer, ls_types from solidlsp.ls import LSPFileBuffer -from solidlsp.ls_utils import PathUtils, TextUtils +from solidlsp.ls_utils import PathUtils, TextStepper, TextUtils from .project import Project from .util.file_proxy import FileProxy @@ -242,9 +242,31 @@ class CodeEditor(Generic[TSymbol], ABC): symbol = self._find_unique_symbol(name_path, relative_file_path) start_pos = symbol.get_body_start_position_or_raise() end_pos = symbol.get_body_end_position_or_raise() + with self.edited_file_context(relative_file_path) as edited_file: + # do the actual deletion edited_file.delete_text_between_positions(start_pos, end_pos) + # empty line removal heuristic: + # After the deletion, go to the line where the deleted symbol started, + # check whether it and any subsequent lines are empty, and if so, delete them. + # The empty lines preceding the deleted symbol's body should normally suffice as a separator + # between the symbols before and after the deleted symbol. + start_pos.col = 0 + stepper = TextStepper(edited_file.get_contents()) + stepper.step_to(start_pos.line, start_pos.col) + apply_empty_line_deletion = False + while stepper.step_line(): + line = stepper.get_last_line(with_end=False) + if line.strip() == "": + end_pos.line = stepper.line + end_pos.col = stepper.col + apply_empty_line_deletion = True + else: + break + if apply_empty_line_deletion: + edited_file.delete_text_between_positions(start_pos, end_pos) + @abstractmethod def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str: pass diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index f00e1f04..ea4b5641 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -122,6 +122,21 @@ class TextStepper: self.is_newline = False return True + def step_to(self, line: int, col: int): + """ + Steps through the text until the given line and column are reached, or until the end of the text is reached. + + :param line: the 0-based line number to step to + :param col: the 0-based column number to step to + """ + while self.line < line: + if not self.step_line(): + break + if self.line != line: + raise InvalidTextLocationError + self.idx += col + self.col = col + def process_all(self): """ Processes all characters in the text, updating the line and column numbers accordingly. @@ -129,7 +144,7 @@ class TextStepper: while self.step_line(): pass - def _get_last_line(self, with_end: bool) -> str: + def get_last_line(self, with_end: bool) -> str: """ Returns the last line processed, optionally including the newline character(s) at the end """ @@ -147,7 +162,7 @@ class TextStepper: lines = [] while self.step_line(): if self.is_newline: - lines.append(self._get_last_line(with_end=with_ends)) + lines.append(self.get_last_line(with_end=with_ends)) # add the last line (which was not followed by a newline), even if empty last_line = self._chars[self.line_start_idx :] diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py index 97a02aa6..669740fe 100644 --- a/test/serena/test_symbol_editing.py +++ b/test/serena/test_symbol_editing.py @@ -216,7 +216,7 @@ class EditingTest(ABC): with open(file_path, encoding="utf-8") as f: return f.read() - def run_test(self, content_after_ground_truth: SnapshotAssertion) -> None: + def run_test(self, content_after_ground_truth: SnapshotAssertion | None) -> None: with self._setup() as symbol_retriever: content_before = self._read_file(self.rel_path) code_editor = LanguageServerCodeEditor(symbol_retriever) @@ -229,9 +229,10 @@ class EditingTest(ABC): def _apply_edit(self, code_editor: CodeEditor) -> None: pass - def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None: + def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion | None) -> None: assert code_diff.has_changes, f"Sanity check failed: No changes detected in {code_diff.relative_path}" - assert code_diff.modified_content == snapshot + if snapshot is not None: + assert code_diff.modified_content == snapshot # Python test file path @@ -483,7 +484,7 @@ class RenameSymbolTest(EditingTest): code_editor.rename_symbol(self.symbol_name, self.rel_path, self.new_name) @overrides - def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion) -> None: + def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion | None) -> None: # sanity check (e.g., for newly generated snapshots) that the new name is actually in the modified content assert self.new_name in code_diff.modified_content, f"New name '{self.new_name}' not found in modified content." return super()._test_diff(code_diff, snapshot) @@ -500,6 +501,49 @@ def test_rename_symbol(snapshot: SnapshotAssertion): test_case.run_test(content_after_ground_truth=snapshot) +class InsertAndDeleteSymbolTest(EditingTest): + """ + Inserts a symbol after another symbol and then deletes the inserted symbol, which should result + in no change to the file content. + """ + + def __init__(self, language: Language, rel_path: str, rel_symbol: str, deleted_symbol: str, insertion: str): + """ + :param language: specifies the language server to use + :param rel_path: relative path of the file + :param rel_symbol: symbol after which the insertion is made + :param deleted_symbol: the symbol to be deleted after the insertion + :param insertion: text which inserts the symbol `deleted_symbol` + """ + super().__init__(language, rel_path) + self.rel_symbol = rel_symbol + self.deleted_symbol = deleted_symbol + self.rel_path = rel_path + self.insertion = insertion + + def _apply_edit(self, code_editor: CodeEditor) -> None: + code_editor.insert_after_symbol(self.rel_symbol, self.rel_path, self.insertion) + code_editor.delete_symbol(self.deleted_symbol, self.rel_path) + + def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion | None) -> None: + assert not code_diff.has_changes + + +def test_insert_and_delete_no_change(): + """ + Tests that inserting and immediately deleting a symbol results in no change to the file content. + """ + insertion = " def inserted_function():\n pass" + test_case = InsertAndDeleteSymbolTest( + Language.PYTHON, + PYTHON_TEST_REL_FILE_PATH, + "VariableContainer/modify_instance_var", + "VariableContainer/inserted_function", + insertion, + ) + test_case.run_test(None) + + # ===== VUE WRITE OPERATIONS TESTS ===== VUE_TEST_FILE = os.path.join("src", "components", "CalculatorButton.vue") diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py index 0d0fca42..f6122148 100644 --- a/test/solidlsp/test_text_utils.py +++ b/test/solidlsp/test_text_utils.py @@ -1,6 +1,6 @@ import pytest -from solidlsp.ls_utils import InvalidTextLocationError, TextUtils +from solidlsp.ls_utils import InvalidTextLocationError, TextStepper, TextUtils class TestTextUtils: @@ -35,6 +35,13 @@ class TestTextUtils: assert TextUtils.get_index_from_line_col(self.TEXT, 1, 1) == 3 + 1 + 1 assert TextUtils.get_index_from_line_col(self.TEXT, 2, 1) == 3 + 1 + 3 + 2 + 1 + def test_step_to(self): + stepper = TextStepper(self.TEXT) + stepper.step_to(2, 1) + assert stepper.line == 2 + assert stepper.col == 1 + assert stepper.idx == 3 + 1 + 3 + 2 + 1 + def test_insert_text_at_index(self): insertion = "XXX" new_text, l, c = TextUtils.insert_text_at_position(self.TEXT, 0, 1, insertion) From d0aea5fd06604e9e70854caddfb7e864dec3d03a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 17 Jul 2026 15:01:20 +0200 Subject: [PATCH 145/170] Update test snapshots following updates to symbol delete logic, improving base Python file (which contained atypical comments) --- .../python/test_repo/test_repo/variables.py | 3 --- .../__snapshots__/test_symbol_editing.ambr | 26 ------------------- test/solidlsp/python/test_symbol_retrieval.py | 12 +++++---- 3 files changed, 7 insertions(+), 34 deletions(-) diff --git a/test/resources/repos/python/test_repo/test_repo/variables.py b/test/resources/repos/python/test_repo/test_repo/variables.py index 81d6fbde..62241660 100644 --- a/test/resources/repos/python/test_repo/test_repo/variables.py +++ b/test/resources/repos/python/test_repo/test_repo/variables.py @@ -20,7 +20,6 @@ reassignable_module_var = 20 # Reassigned typed_module_var: int = 42 -# Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" @@ -59,7 +58,6 @@ class VariableContainer: return result, other_result -# Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" @@ -75,7 +73,6 @@ class VariableDataclass: status: str = "pending" -# Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index ec23cf33..7e6c6a8f 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -23,11 +23,6 @@ typed_module_var: int = 42 - # Regular class with class and instance variables - - - - # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" @@ -43,7 +38,6 @@ status: str = "pending" - # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" @@ -69,8 +63,6 @@ ''' import { ConsoleGreeter, Greeter } from "./formatters"; - - export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); @@ -334,7 +326,6 @@ typed_module_var: int = 42 - # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" @@ -373,7 +364,6 @@ return result, other_result - # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" @@ -389,7 +379,6 @@ status: str = "pending" - # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" @@ -438,7 +427,6 @@ typed_module_var: int = 42 - # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" @@ -477,7 +465,6 @@ return result, other_result - # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" @@ -493,7 +480,6 @@ status: str = "pending" - # Function that uses the module variables def new_inserted_function(): print("This is a new function inserted before another.") @@ -1138,7 +1124,6 @@ typed_module_var: int = 42 - # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" @@ -1177,7 +1162,6 @@ return result, other_result - # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" @@ -1197,7 +1181,6 @@ pass - # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" @@ -1243,7 +1226,6 @@ typed_module_var: int = 42 - # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" @@ -1282,7 +1264,6 @@ return result, other_result - # Dataclass with variables class NewInsertedClass: pass @@ -1302,7 +1283,6 @@ status: str = "pending" - # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" @@ -1531,7 +1511,6 @@ renamed_typed_module_var: int = 42 - # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" @@ -1570,7 +1549,6 @@ return result, other_result - # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" @@ -1586,7 +1564,6 @@ status: str = "pending" - # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" @@ -1632,7 +1609,6 @@ typed_module_var: int = 42 - # Regular class with class and instance variables class VariableContainer: """Class that contains various variables.""" @@ -1671,7 +1647,6 @@ return result, other_result - # Dataclass with variables @dataclass class VariableDataclass: """Dataclass that contains various fields.""" @@ -1687,7 +1662,6 @@ status: str = "pending" - # Function that uses the module variables def use_module_variables(): """Function that uses module-level variables.""" result = module_var + " used in function" diff --git a/test/solidlsp/python/test_symbol_retrieval.py b/test/solidlsp/python/test_symbol_retrieval.py index b95155d2..38185f31 100644 --- a/test/solidlsp/python/test_symbol_retrieval.py +++ b/test/solidlsp/python/test_symbol_retrieval.py @@ -11,6 +11,7 @@ import os import pytest from serena.symbol import LanguageServerSymbol +from serena.util.text_utils import find_text_coordinates from solidlsp import SolidLanguageServer from solidlsp.ls_types import SymbolKind from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES @@ -40,14 +41,15 @@ class TestLanguageServerSymbols: def test_references_to_variables(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a variable.""" file_path = os.path.join("test_repo", "variables.py") - # Line 75 contains the field status that is later modified - ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 74, 4)] + + # find references to field `status` + with language_server.open_file(file_path, open_in_ls=False) as f: + file_content = f.contents + coords = find_text_coordinates(file_content, r"(status): str") + ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, coords.line, coords.col)] assert len(ref_symbols) > 0 - ref_lines = [ref["location"]["range"]["start"]["line"] for ref in ref_symbols if "location" in ref and "range" in ref["location"]] ref_names = [ref["name"] for ref in ref_symbols] - assert 87 in ref_lines - assert 95 in ref_lines assert "dataclass_instance" in ref_names assert "second_dataclass" in ref_names From bd9c4c9e89e2325f739300bfb2f3f13be19b2c04 Mon Sep 17 00:00:00 2001 From: zerone0x <hi@trine.dev> Date: Sat, 13 Jun 2026 04:42:27 +0200 Subject: [PATCH 146/170] fix: improve ignored_paths YAML guidance Fixes #1561 Co-Authored-By: Claude <noreply@anthropic.com> --- src/serena/resources/project.template.yml | 7 +++ .../resources/serena_config.template.yml | 1 + src/serena/util/yaml.py | 12 +++++- test/serena/config/test_serena_config.py | 43 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index a4dddb37..dd3a4d7b 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -71,6 +71,13 @@ additional_workspace_folders: [] # list of additional paths to ignore in this project. # Same syntax as gitignore, so you can use * and **. +# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases. +# Example: +# ignored_paths: +# - "examples/**" +# - ".worktrees/**" +# - "**/bin/**" +# - "**/obj/**" # Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index fb5c1d40..a5d81d0d 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -103,6 +103,7 @@ ls_specific_settings: {} # list of paths to ignore across all projects. # Same syntax as gitignore, so you can use * and **. # These patterns are merged additively with each project's own ignored_paths. +# Quote patterns that start with `*`, e.g. `"**/bin/**"`. ignored_paths: [] # list of regex patterns which, when matched, mark a memory entry as read‑only. diff --git a/src/serena/util/yaml.py b/src/serena/util/yaml.py index ca63eee7..b1261e01 100644 --- a/src/serena/util/yaml.py +++ b/src/serena/util/yaml.py @@ -68,7 +68,17 @@ def load_yaml(path: str, comment_normalisation: YamlCommentNormalisation = YamlC """ with open(path, encoding=SERENA_FILE_ENCODING) as f: yaml = _create_yaml(preserve_comments=True) - commented_map: CommentedMap | None = yaml.load(f) + try: + commented_map: CommentedMap | None = yaml.load(f) + except Exception as e: + msg = str(e) + if "undefined alias" in msg: + raise ValueError( + f"Invalid YAML in {path}: values that start with `*` must be quoted. " + "This often happens in `ignored_paths` when using gitignore-style globs like " + '`"**/bin/**"` or `"**/obj/**"`.' + ) from e + raise if commented_map is None: # ruamel returns None for empty documents, but we want an empty CommentedMap commented_map = CommentedMap() normalise_yaml_comments(commented_map, comment_normalisation) diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 2b0a4d52..7043ed9b 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -499,6 +499,25 @@ class TestProjectSerenaDataFolder: assert project.path_to_serena_data_folder() == str(custom_serena) +class TestProjectConfigYamlValidation: + def test_ignored_paths_globs_starting_with_star_require_quotes(self): + project_dir = Path(tempfile.mkdtemp()) + try: + serena_dir = project_dir / SERENA_MANAGED_DIR_NAME + serena_dir.mkdir(parents=True) + (serena_dir / "project.yml").write_text('project_name: "demo"\nlanguages: ["csharp"]\nignored_paths:\n- **/bin/**\n') + + with pytest.raises(ValueError) as exc_info: + ProjectConfig.load(project_dir, create_default_serena_config()) + + msg = str(exc_info.value) + assert "values that start with `*` must be quoted" in msg + assert "ignored_paths" in msg + assert '"**/bin/**"' in msg + finally: + shutil.rmtree(project_dir) + + class TestSerenaConfigFromConfigFileRobustness: """Tests that ``SerenaConfig.from_config_file`` does not abort the whole loader when a single registered project has a broken ``project.yml``. @@ -568,6 +587,30 @@ class TestSerenaConfigFromConfigFileRobustness: f"Expected a warning naming {bad_project.resolve()}, got: {caplog.messages}" ) + def test_alias_like_ignored_path_error_is_logged_with_hint(self, caplog, monkeypatch): + good_project = self._make_project_dir( + "good_project", + 'project_name: "good_project"\nlanguages: ["python"]\n', + ) + bad_project = self._make_project_dir( + "bad_project", + 'project_name: "bad_project"\nlanguages: ["csharp"]\nignored_paths:\n- **/bin/**\n', + ) + self._write_master_config([good_project, bad_project]) + + monkeypatch.setattr( + SerenaConfig, + "_determine_config_file_path", + classmethod(lambda cls: str(self.master_config_path)), + ) + + with caplog.at_level(logging.ERROR): + config = SerenaConfig.from_config_file(generate_if_missing=False) + + registered_roots = {Path(p.project_root).resolve() for p in config.projects} + assert registered_roots == {good_project.resolve()} + assert any("must be quoted" in msg and str(bad_project.resolve()) in msg for msg in caplog.messages), caplog.messages + class TestMemoriesManagerCustomPath: """Tests for MemoriesManager with a custom serena data folder.""" From 3a67ea2086e33a9fa0148d808e35ba09be738b92 Mon Sep 17 00:00:00 2001 From: Amir Fathi <amirfathi.me@gmail.com> Date: Thu, 16 Jul 2026 11:15:22 +0000 Subject: [PATCH 147/170] fix(ls): correct SymbolBody.get_text for the one-line-past-EOF range An LSP server can end a symbol range one line past EOF: the convention for a range covering whole lines ends it at the start of the following, non-existent line. SymbolBody.get_text indexes self._lines[end_line] directly, so that well-defined case raised IndexError. get_text now corrects exactly that case (end_line == len(lines), end_col=0) to end at the actual last line, in get_text itself so it also applies to a cached SymbolBody, not just a freshly constructed one. Any other out-of-range end_line now raises InvalidTextLocationError. Fixes #1498 --- CHANGELOG.md | 5 ++ src/solidlsp/ls.py | 31 +++++++++-- src/solidlsp/ls_exceptions.py | 9 ++++ test/solidlsp/test_symbol_body.py | 89 +++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 test/solidlsp/test_symbol_body.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8e53e4..48e059c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Status of the `main` branch. Changes prior to the next official version change w reached Serena's in-memory file contents, where the rest of the code assumes LF-normalized text and the `line_ending` setting is meant to be the single point of line-ending translation on write. The fallback now normalizes line endings to LF, consistently with the primary path. + - Fix: a symbol whose LSP range ended exactly one line past EOF, at column 0 (the convention for + a range covering whole lines through the end of the file), raised `IndexError` in + `SymbolBody.get_text`. That one well-defined case is now corrected to end at the actual last + line; any other out-of-range end position now raises `InvalidTextLocationError` instead, + rather than guessing at a body that could be wrong #1498 * Tools: - Fix: `search_for_pattern` marked one line too many as matched whenever a match ended with a line diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 82ac0c30..8b05a32f 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -32,7 +32,7 @@ from solidlsp.dependency_provider import ( ) from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig -from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_exceptions import InvalidTextLocationError, SolidLSPException from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils @@ -209,17 +209,38 @@ class SymbolBody(ToStringMixin): return ["_lines"] def get_text(self) -> str: + end_line = self._end_line + end_col = self._end_col + if end_line >= len(self._lines): + if end_line == len(self._lines) and end_col == 0: + # LSP convention: a range covering whole lines through EOF sometimes ends + # at the start of the following, non-existent line (exactly one line past + # the last valid index, at column 0). That is well-defined: it means + # "through EOF", so treat it as ending at the end of the actual last line. + end_line = len(self._lines) - 1 + end_col = len(self._lines[end_line]) + else: + # Any other out-of-range end position (further past EOF, or exactly one + # line past EOF but not at column 0) is not the well-defined convention + # above; applying the same correction there would silently assume that + # a column meant for a nonexistent line still applies to the corrected + # one, which can produce a garbage body. Reject it instead of guessing. + raise InvalidTextLocationError( + f"Symbol range end (line {self._end_line}, col {self._end_col}) is out of bounds " + f"for a file with {len(self._lines)} lines" + ) + # extract relevant lines - symbol_body = "\n".join(self._lines[self._start_line : self._end_line + 1]) + symbol_body = "\n".join(self._lines[self._start_line : end_line + 1]) # remove leading content from the first line symbol_body = symbol_body[self._start_col :] # remove trailing content from the last line - last_line = self._lines[self._end_line] - trailing_length = len(last_line) - self._end_col + last_line = self._lines[end_line] + trailing_length = len(last_line) - end_col if trailing_length > 0: - symbol_body = symbol_body[: -(len(last_line) - self._end_col)] + symbol_body = symbol_body[: -(len(last_line) - end_col)] return symbol_body diff --git a/src/solidlsp/ls_exceptions.py b/src/solidlsp/ls_exceptions.py index 1a82f6b6..214b890b 100644 --- a/src/solidlsp/ls_exceptions.py +++ b/src/solidlsp/ls_exceptions.py @@ -52,6 +52,15 @@ class SolidLSPException(Exception): return s +class InvalidTextLocationError(SolidLSPException): + """ + Raised when a symbol's LSP range refers to a text location that does not exist + in the file's current line buffer, other than the well-defined whole-line-through-EOF + convention (end line exactly one past EOF at column 0), which is corrected rather + than rejected. + """ + + class MetalsStaleLockError(SolidLSPException): """ Raised when a stale Metals H2 database lock is detected and the user diff --git a/test/solidlsp/test_symbol_body.py b/test/solidlsp/test_symbol_body.py new file mode 100644 index 00000000..87660bf6 --- /dev/null +++ b/test/solidlsp/test_symbol_body.py @@ -0,0 +1,89 @@ +"""Unit tests for SymbolBody / SymbolBodyFactory that need no running language server.""" + +import pytest + +from solidlsp.ls import SymbolBodyFactory +from solidlsp.ls_exceptions import InvalidTextLocationError + + +class _StubBuffer: + """Minimal stand-in for LSPFileBuffer: the factory only reads split_lines().""" + + def __init__(self, lines: list[str]) -> None: + self._lines = lines + + def split_lines(self) -> list[str]: + return self._lines + + +def _symbol(start_line: int, start_col: int, end_line: int, end_col: int) -> dict: + return { + "location": { + "range": { + "start": {"line": start_line, "character": start_col}, + "end": {"line": end_line, "character": end_col}, + } + } + } + + +# 3 lines, valid indices 0..2 +LINES = ["class Foo:", " var x = 1", " var y = 2"] +FULL = "\n".join(LINES) + + +def _factory() -> SymbolBodyFactory: + return SymbolBodyFactory(_StubBuffer(list(LINES))) + + +def test_get_text_in_bounds_range() -> None: + """A range ending at the last real position returns the whole symbol (control).""" + body = _factory().create_symbol_body(_symbol(0, 0, 2, len(LINES[2]))) + assert body.get_text() == FULL + + +def test_get_text_end_line_past_eof_does_not_raise() -> None: + """A range whose end.line is past EOF used to raise IndexError in get_text. + + The LSP convention for a range covering whole lines ends it at the start of the + following line, which for the last line is one line past EOF. That end position + must be clamped to the end of the file, so the text runs through the last line. + """ + body = _factory().create_symbol_body(_symbol(0, 0, len(LINES), 0)) + assert body.get_text() == FULL + + +def test_get_text_end_col_past_line_end() -> None: + """An end.character past the end of a valid last line is clamped, no over-trim.""" + body = _factory().create_symbol_body(_symbol(0, 0, 2, 999)) + assert body.get_text() == FULL + + +def test_get_text_start_line_past_eof_returns_empty() -> None: + """A start.line past EOF is degenerate; it must not raise and yields no text.""" + body = _factory().create_symbol_body(_symbol(len(LINES), 0, len(LINES), 0)) + assert body.get_text() == "" + + +def test_get_text_end_line_far_past_eof_still_raises() -> None: + """end.line more than one line past EOF is a different, unconfirmed problem. + + Only the single-line-past-EOF case (the documented whole-line-range convention) is + well-defined enough to correct. Anything further out is rejected explicitly, rather + than guessing at a body that could be silently wrong. + """ + body = _factory().create_symbol_body(_symbol(0, 0, len(LINES) + 1, 0)) + with pytest.raises(InvalidTextLocationError): + body.get_text() + + +def test_get_text_end_line_past_eof_with_nonzero_col_raises() -> None: + """end.line one past EOF with a nonzero end.character is not the documented convention. + + The well-defined case is specifically column 0 (the start of the nonexistent + following line). A nonzero column there has no defined meaning for a line that does + not exist, so it must raise rather than being clamped as if it were the same case. + """ + body = _factory().create_symbol_body(_symbol(0, 0, len(LINES), 5)) + with pytest.raises(InvalidTextLocationError): + body.get_text() From 0f5111c17abdb336358a18dff3c16e1dd3fc46fb Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 17 Jul 2026 17:27:04 +0200 Subject: [PATCH 148/170] Replace InvalidTextLocationError with SolidLSPException subclass --- src/solidlsp/ls_utils.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index f00e1f04..26b2a2e9 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -21,16 +21,12 @@ from urllib.parse import urlparse import charset_normalizer import requests -from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_exceptions import InvalidTextLocationError, SolidLSPException from solidlsp.ls_types import UnifiedSymbolInformation log = logging.getLogger(__name__) -class InvalidTextLocationError(Exception): - pass - - class TextStepper: r""" A utility class for stepping through a text string line by line, keeping track of the current line and column numbers. From 369e3a05a0fa94b19ced11879bda3048d46752e3 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Fri, 17 Jul 2026 17:29:33 +0200 Subject: [PATCH 149/170] Remove obsolete Python 2 compatibility code --- src/solidlsp/ls_utils.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 60421b57..c195d401 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -345,19 +345,9 @@ class PathUtils: This method was obtained from https://stackoverflow.com/a/61922504 """ - try: - from urllib.parse import unquote, urlparse - from urllib.request import url2pathname - except ImportError: - # backwards compatibility (Python 2) - from urllib.parse import unquote as unquote_py2 - from urllib.request import url2pathname as url2pathname_py2 + from urllib.parse import unquote, urlparse + from urllib.request import url2pathname - from urlparse import urlparse as urlparse_py2 - - unquote = unquote_py2 - url2pathname = url2pathname_py2 - urlparse = urlparse_py2 parsed = urlparse(uri) host = f"{os.path.sep}{os.path.sep}{parsed.netloc}{os.path.sep}" path = os.path.abspath(os.path.join(host, url2pathname(unquote(parsed.path)))) From 7c34662996f2d2e3db38001785e1f279dced2f50 Mon Sep 17 00:00:00 2001 From: bymebyu <bymebyu@naver.com> Date: Sat, 18 Jul 2026 00:45:09 +0900 Subject: [PATCH 150/170] search_for_pattern: add a snippet stage to the overflow shortening chain (#1667) This adds an intermediate stage returning line numbers plus the matched text truncated to 60 characters. Test contributed by @AmirF194 in review. --- CHANGELOG.md | 3 ++ src/serena/tools/file_tools.py | 53 +++++++++++++++++++++++--- test/serena/test_search_for_pattern.py | 48 +++++++++++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 test/serena/test_search_for_pattern.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b58b52..e085a414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ Status of the `main` branch. Changes prior to the next official version change w resolved to the start of the following line. The line the match ends on is now determined correctly, which also keeps `context_lines_after` aligned. - `safe_delete`: Add heuristic to delete superfluous empty lines after a deletion + - `search_for_pattern`: on overflow, the shortening chain now emits each match's first line (full when + it fits, otherwise truncated with a trailing '...' and a note) before falling back to bare line + numbers, so agents can pick the right match without re-reading files. #1640 * JetBrains: - Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 65ecea7b..3cc55d82 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -611,15 +611,48 @@ class SearchForPatternTool(Tool): file_to_matches[match.source_file_path].append(match.to_display_string()) # capture lightweight match data for shortening before serialization - match_lines_by_file: dict[str, list[int]] = defaultdict(list) + match_lines_by_file: dict[str, list[dict[str, int | str]]] = defaultdict(list) for match in matches: assert match.source_file_path is not None - match_lines_by_file[match.source_file_path].append(match.matched_lines[0].line_number) + first = match.matched_lines[0] + match_lines_by_file[match.source_file_path].append({"line": first.line_number, "text": first.line_content.strip()}) # shortened result closures, from least to most aggressive shortening - def make_lines_only() -> str: - """Match locations without surrounding context""" - return f"Match lines per file:\n{self._to_json(match_lines_by_file)}" + _TEXT_TRUNCATE = 60 + + def render_first_lines(truncate: bool) -> str: + """Render each match's first line, either in full or truncated to a fixed length.""" + + def entry_text(text: str) -> str: + if truncate and len(text) > _TEXT_TRUNCATE: + return text[:_TEXT_TRUNCATE] + "..." + return text + + compact = { + path: [{"line": m["line"], "text": entry_text(str(m["text"]))} for m in lines] + for path, lines in match_lines_by_file.items() + } + if truncate: + header = ( + f"Matched lines (text over {_TEXT_TRUNCATE} chars is truncated, marked with a trailing '...'); " + "use read_file with the line numbers for full content:" + ) + else: + header = "Matched lines per file; use read_file with the line numbers for surrounding context:" + return f"{header}\n{self._to_json(compact)}" + + def make_first_lines_full() -> str: + """Match locations with each match's full first line.""" + return render_first_lines(truncate=False) + + def make_first_lines_truncated() -> str: + """Match locations with each match's first line truncated to a fixed length.""" + return render_first_lines(truncate=True) + + def make_line_numbers_only() -> str: + """Match locations as bare line numbers (no text).""" + numbers = {path: [m["line"] for m in lines] for path, lines in match_lines_by_file.items()} + return f"Match lines per file:\n{self._to_json(numbers)}" def make_per_file_counts() -> str: counts = {path: len(lines) for path, lines in match_lines_by_file.items()} @@ -630,5 +663,13 @@ class SearchForPatternTool(Tool): result = self._to_json(file_to_matches) return self._limit_length( - result, max_answer_chars, shortened_result_factories=[make_lines_only, make_per_file_counts, make_summary] + result, + max_answer_chars, + shortened_result_factories=[ + make_first_lines_full, + make_first_lines_truncated, + make_line_numbers_only, + make_per_file_counts, + make_summary, + ], ) diff --git a/test/serena/test_search_for_pattern.py b/test/serena/test_search_for_pattern.py new file mode 100644 index 00000000..c00107d3 --- /dev/null +++ b/test/serena/test_search_for_pattern.py @@ -0,0 +1,48 @@ +"""Tests for the ``SearchForPatternTool`` overflow shortening chain. + +The snippet stage and, in particular, its position in the shortening chain were +previously untested. Test contributed by @AmirF194 in review of PR #1667. +""" + +from unittest.mock import MagicMock + +from serena.config.serena_config import SerenaConfig +from serena.project import Project +from serena.tools.file_tools import SearchForPatternTool + + +def test_search_for_pattern_snippet_stage(tmp_path): + lines: list[str] = [] + for i in range(60): + lines += [ + "filler above", + "filler above", + f"MATCHME item number {i:04d} " + "payload " * 6, + "filler below", + "filler below", + ] + (tmp_path / "data.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + + project = Project.load(str(tmp_path), serena_config=SerenaConfig(gui_log_window=False, web_dashboard=False)) + agent = MagicMock() + agent.get_active_project_or_raise.return_value = project + tool = SearchForPatternTool(agent) + + def run(cap: int) -> str: + return tool.apply( + substring_pattern="MATCHME", + context_lines_before=2, + context_lines_after=2, + restrict_search_to_code_files=False, + max_answer_chars=cap, + ) + + # wide but overflowing cap: the snippet stage (line + matched text) is returned + snippet = run(7000) + assert "The answer is too long" in snippet + assert '"text":' in snippet and "MATCHME item number 0000" in snippet + assert "Match lines per file" not in snippet # not the bare-line-numbers stage + + # tighter cap: the chain degrades past the snippet stage to bare line numbers + bare = run(1000) + assert "Match lines per file" in bare and '"text":' not in bare From d88ebafee7113f523bcd89d5fd71a11611bdfbcc Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Sat, 18 Jul 2026 11:15:44 +0200 Subject: [PATCH 151/170] Add news on trusted projects --- news/20260718.html | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 news/20260718.html diff --git a/news/20260718.html b/news/20260718.html new file mode 100644 index 00000000..710eabe4 --- /dev/null +++ b/news/20260718.html @@ -0,0 +1,19 @@ +<div class="news-item"> + <h3>Trusted Projects</h3> + <p class="date">July 18, 2026</p> + <p> + Serena v1.6.0 introduced the notion of <i>trusted projects</i>.<br> + In particular, only trusted projects are allowed to run activation commands and configure language server-specific + settings at the project level. + </p> + <p> + To not break existing workflows, existing Serena configurations were migrated to trust all projects + by default (whereas new installations will have no trusted projects by default). + </p> + <p> + <b>Configure trusted projects!</b> + If you sometimes activate projects from untrusted sources, we highly recommend that you adjust + the <code>trusted_project_path_patterns</code> setting in + <a href="https://oraios.github.io/serena/02-usage/050_configuration.html#global-configuration" target="_blank">Serena's global configuration</a>. + </p> +</div> From 96e95c0b5dfaf21a277a1478e72c11f9383a9c9a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Sat, 18 Jul 2026 12:59:14 +0200 Subject: [PATCH 152/170] Adjust Docker compose template (compose.yaml): Expose ports to host only --- compose.yaml | 4 ++-- docs/02-usage/020_running.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.yaml b/compose.yaml index d7c8becd..edd530f0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -10,8 +10,8 @@ services: dockerfile: Dockerfile target: production ports: - - "${SERENA_PORT:-9121}:9121" # MCP server port - - "${SERENA_DASHBOARD_PORT:-24282}:24282" # Dashboard port (default 0x5EDA = 24282) + - "127.0.0.1:${SERENA_PORT:-9121}:9121" + - "127.0.0.1:${SERENA_DASHBOARD_PORT:-24282}:24282" environment: - SERENA_DOCKER=1 command: diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index 84de5490..26eb9b2e 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -241,7 +241,7 @@ docker run --rm -i --network host -v /path/to/your/projects:/workspaces/projects This command mounts your projects into the container under `/workspaces/projects`, so when working with projects, you need to refer to them using the respective path (e.g. `/workspaces/projects/my-project`). -Alternatively, you may use Docker compose with the `compose.yml` file provided in the repository. +Alternatively, you may use Docker compose. Adjust the file `compose.yml`, which is provided in the repository, according to your needs. See our [advanced Docker usage](https://github.com/oraios/serena/blob/main/DOCKER.md) documentation for more detailed instructions, configuration options, and limitations. :::{note} From a9067050f23bfbec84d8cec6e238fdf017126235 Mon Sep 17 00:00:00 2001 From: dev_Hakaze <af.nawfal@gmail.com> Date: Sun, 19 Jul 2026 19:00:33 +0700 Subject: [PATCH 153/170] Fix: dashboard "Last Execution" panel stuck on "Loading..." when no execution exists (#1714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadLastExecution()'s success handler only called displayLastExecution() when the backend returned a non-null last_execution, so on a fresh server (no tool run yet) the initial "Loading..." placeholder was never replaced. displayLastExecution(null) already renders the correct empty state ("No executions yet.") — it just wasn't being called. Add the missing else branch, mirroring how the Tool Usage and Executions Queue panels already handle their empty states. Fixes #1713 Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com> --- CHANGELOG.md | 6 ++++++ src/serena/resources/dashboard/dashboard.js | 2 ++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e085a414..6f86e376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ Status of the `main` branch. Changes prior to the next official version change w - used in `JetBrainsFindDeclarationTool` when using plugin version 2023.3.3+ +* Dashboard: + - Fix: the "Last Execution" panel stayed on "Loading..." forever when there was no logged execution + (e.g. a fresh server / no tool run yet), because `loadLastExecution()` only rendered the panel when + the backend returned a non-null execution; the empty state is now rendered via the existing + `displayLastExecution(null)` path, mirroring the other panels #1713 + * Dependencies: - Bump mcp from 1.27.0 to 1.28.1 diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index 43987b58..571df521 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -913,6 +913,8 @@ class Dashboard { if (response.status === 'success') { if (response.last_execution !== null && response.last_execution.logged) { self.displayLastExecution(response.last_execution); + } else { + self.displayLastExecution(null); } } else { console.error('Error loading last execution:', response.message); From 18d4cc007b8d460f3ddba13cd95b995ff39760c5 Mon Sep 17 00:00:00 2001 From: dev_Hakaze <af.nawfal@gmail.com> Date: Sun, 19 Jul 2026 21:03:25 +0700 Subject: [PATCH 154/170] Java (JDT-LS): allow registering additional JRE/JDK runtimes via config (#1715) * Java (JDT-LS): allow registering additional JRE/JDK runtimes via config JDT-LS resolves each imported module's JRE_CONTAINER/.../JavaSE-NN/ classpath entry against its own "Installed JREs" list, which Serena previously hardcoded to a single JavaSE-21 entry pointing at the bundled JRE. Projects whose source/target level exceeds that bundled JDK (e.g. sourceCompatibility = VERSION_25) get a container that never resolves, so every JDK type -- java.lang.Object, java.util.*, etc. -- comes back "cannot be resolved", silently breaking diagnostics and cross-module resolution. There was no config key to tell JDT-LS about an installed newer JDK. Add an optional `runtimes` list under ls_specific_settings.java, mirroring VS Code's java.configuration.runtimes shape (name, path, optional default/sources/javadoc). Entries are validated (name+path required, path must exist) via the new _resolve_configured_runtimes(), then merged with the bundled JavaSE-21 default: configured runtimes extend the list; an entry reusing the "JavaSE-21" name overrides the bundled one; if a configured entry claims `default`, the bundled runtime's own default flag is cleared so JDT-LS doesn't see two defaults. The `runtimes` key is also added to the JDTLS workspace-hash inputs so changing it lands in a fresh workspace instead of reusing a stale Buildship/Maven import. Testing: added 14 unit tests in test/solidlsp/java/test_jdtls_path_resolution.py covering _resolve_configured_runtimes validation (missing keys, wrong types, nonexistent paths) and the merge behavior in _create_base_initialize_params (extension, name-collision override, default hand-off). Ran `poe format`, `poe type-check`, and `pytest test/solidlsp/java -m "not java"` (69 passed, 8 deselected real-JDTLS tests that require a JDK) locally. Fixes #1478 * docs: document java.runtimes in configuration guide Address review feedback on #1715 by documenting ls_specific_settings.java.runtimes in 050_configuration.md (settings table, note, and example). Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: arimu1 <19286898+arimu1@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> --- CHANGELOG.md | 8 ++ docs/02-usage/050_configuration.md | 18 +++ .../language_servers/eclipse_jdtls.py | 80 ++++++++++- .../java/test_jdtls_path_resolution.py | 129 ++++++++++++++++++ 4 files changed, 231 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f86e376..91b97747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ Status of the `main` branch. Changes prior to the next official version change w line; any other out-of-range end position now raises `InvalidTextLocationError` instead, rather than guessing at a body that could be wrong #1498 +* Language Servers: + - Java (JDT-LS): add `runtimes` to `ls_specific_settings.java`, a list of extra JRE/JDK entries + (`name`, `path`, optional `default`/`sources`/`javadoc`) passed through to JDT-LS's + `java.configuration.runtimes`. Fixes silently broken JDK type resolution (`java.lang.Object` + and other JDK types reported as "cannot be resolved") for projects whose source/target level + exceeds the bundled JDK 21 JRE JDT-LS registers by default; configured runtimes extend rather + than replace that bundled default. #1478 + * Tools: - Fix: `search_for_pattern` marked one line too many as matched whenever a match ended with a line break, because the match's exclusive end index was mapped to a line number directly and therefore diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 21813640..469ef566 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -697,6 +697,7 @@ The following settings are supported for the Java language server: | `gradle_wrapper_enabled` | `false` | Use the project's Gradle wrapper (`gradlew`) instead of the bundled Gradle distribution. Enable this for projects with custom plugins or repositories. | | `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses `JAVA_HOME` if `use_system_java_home` is enabled and `JAVA_HOME` is set; otherwise it falls back to Serena's bundled JRE. | | `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself and, when `gradle_java_home` is unset, Gradle import. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. | +| `runtimes` | `[]` | Extra JRE/JDK entries registered with JDT-LS via `java.configuration.runtimes`. Use this when a project's source/target level exceeds the JDK JDT-LS itself runs on (currently JDK 21 in default vscode-java VSIX mode). Each entry is a mapping with required `name` (e.g. `JavaSE-25`, matching the `JavaSE-NN` container the build tool requests) and `path` (JDK/JRE home directory; must exist), plus optional `default`, `sources`, and `javadoc` (passed through to JDT-LS). Entries extend rather than replace the bundled `JavaSE-21` runtime; an entry that reuses the `JavaSE-21` name overrides the bundled one. Changing this setting invalidates the JDTLS workspace hash so a fresh import is performed. | | `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. | | `vscode_java_version` | `1.54.0-923` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. | | `intellicode_version` | `1.2.30` | (vscode-java mode only) Override the IntelliCode VSIX version Serena downloads by default. | @@ -716,6 +717,10 @@ Notes: - In upstream-jdtls mode the `gradle_version`, `vscode_java_version`, `intellicode_version`, `intellicode_xmx`, `intellicode_xms` settings are silently ignored — they only apply to the vscode-java VSIX mode. +- Without `runtimes`, JDT-LS only knows about the bundled `JavaSE-21` JRE. Projects that request a newer + container (e.g. `sourceCompatibility = JavaVersion.VERSION_25`) then fail to resolve JDK types such as + `java.lang.Object`. Register the matching installed JDK via `runtimes` instead of symlinking over Serena's + bundled JRE directory. Example: upstream-jdtls mode (offline / corporate network): @@ -736,6 +741,19 @@ ls_specific_settings: use_system_java_home: true ``` +Example: register an additional JDK for a project targeting a newer Java version: + +```yaml +ls_specific_settings: + java: + runtimes: + - name: JavaSE-21 + path: /usr/lib/jvm/java-21-openjdk + - name: JavaSE-25 + path: /home/user/Java/jdk25 + default: true +``` + #### Kotlin Serena uses [JetBrains' Kotlin Language Server](https://github.com/Kotlin/kotlin-lsp) for Kotlin support. diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 1539002b..b4b86e96 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -15,7 +15,7 @@ import subprocess import threading from pathlib import Path, PurePath from time import sleep -from typing import cast +from typing import Any, cast from overrides import override @@ -199,6 +199,17 @@ class EclipseJDTLS(SolidLanguageServer): are not supported in default VSIX mode (the resource paths inside the archive change between releases); use upstream-jdtls mode for arbitrary versions. - intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena + - runtimes: Additional JRE/JDK entries to register with JDT-LS's ``java.configuration.runtimes``, + for projects whose source/target level exceeds the JDK JDT-LS itself runs on (currently + JDK 21 in default vscode-java VSIX mode). Each entry is a mapping with: + - name (required): the JRE container name JDT-LS should register the entry under, + e.g. "JavaSE-25" (must match the ``JavaSE-NN`` the build tool requests). + - path (required): filesystem path to the JDK/JRE home directory; must exist. + - default (optional): whether this runtime is JDT-LS's default when no container name matches. + - sources / javadoc (optional): passed through unchanged to JDT-LS. + These entries extend rather than replace the bundled JRE, which is still registered as + "JavaSE-21" unless a configured entry reuses that same name (in which case it is overridden). + See serena #1478. Example configuration for upstream JDTLS mode (no downloads, suitable for offline/corporate): ```yaml @@ -228,6 +239,10 @@ class EclipseJDTLS(SolidLanguageServer): gradle_version: "8.14.2" vscode_java_version: "1.54.0-923" # also accepts pinned legacy "1.42.0-561" intellicode_version: "1.2.30" + runtimes: # register additional JDKs for projects targeting a newer Java version + - name: "JavaSE-25" + path: "/home/user/Java/jdk25" + default: true ``` """ @@ -779,6 +794,7 @@ class EclipseJDTLS(SolidLanguageServer): "java_home", "use_system_java_home", "maven_offline", + "runtimes", ) workspace_settings = {key: custom_settings.settings[key] for key in workspace_setting_keys if key in custom_settings.settings} workspace_settings_json = json.dumps(workspace_settings, sort_keys=True, separators=(",", ":")) @@ -922,6 +938,52 @@ class EclipseJDTLS(SolidLanguageServer): log.info(f"Using bundled JRE for Gradle: {self.runtime_dependency_paths.jre_path}") return self.runtime_dependency_paths.jre_path + def _resolve_configured_runtimes(self) -> list[dict[str, Any]]: + """ + Validate and normalize the optional extra JRE/JDK runtimes to register with JDT-LS, as configured + via ``ls_specific_settings.java.runtimes``. Each entry mirrors the shape VS Code's Java extension + sends via ``java.configuration.runtimes`` (``name``, ``path``, optional ``default``/``sources``/ + ``javadoc``); see serena #1478. + + :return: the validated list of runtime dicts (empty if the setting is unset) + :raises ValueError: if the setting or one of its entries is malformed + :raises FileNotFoundError: if an entry's ``path`` does not exist + """ + configured_runtimes = self._custom_settings.get("runtimes", []) + if not isinstance(configured_runtimes, list): + raise ValueError( + f"ls_specific_settings.java.runtimes must be a list of {{name, path}} entries, " + f"got {type(configured_runtimes).__name__}: {configured_runtimes!r}" + ) + + # validate each entry and normalize it to the shape JDT-LS expects... + validated_runtimes: list[dict[str, Any]] = [] + for entry in configured_runtimes: + if not isinstance(entry, dict) or "name" not in entry or "path" not in entry: + raise ValueError( + f"Invalid ls_specific_settings.java.runtimes entry {entry!r}: each entry requires at least a 'name' and a 'path' key." + ) + path = entry["path"] + if not os.path.exists(path): + error_msg = ( + f"ls_specific_settings.java.runtimes entry '{entry['name']}' points to a path that " + f"does not exist: {path}. Fix: update the path in ~/.serena/serena_config.yml " + f"(ls_specific_settings -> java -> runtimes), or remove the entry." + ) + log.error(error_msg) + raise FileNotFoundError(error_msg) + + runtime: dict[str, Any] = {"name": entry["name"], "path": path} + if "default" in entry: + runtime["default"] = bool(entry["default"]) + for optional_key in ("sources", "javadoc"): + if optional_key in entry: + runtime[optional_key] = entry[optional_key] + validated_runtimes.append(runtime) + log.info(f"Registering additional JDT-LS runtime '{runtime['name']}' from custom settings: {path}") + + return validated_runtimes + def _create_base_initialize_params(self) -> dict: """ Returns the initialize parameters for the EclipseJDTLS server. @@ -1304,9 +1366,19 @@ class EclipseJDTLS(SolidLanguageServer): else: initialize_params["initializationOptions"]["bundles"] = [] - initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ - {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} - ] + # merge the bundled JRE with any additional runtimes configured via ls_specific_settings.java.runtimes + # (e.g. so projects targeting a newer Java version than the bundled JRE resolve their JRE container)... + default_runtime = {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} + configured_runtimes = self._resolve_configured_runtimes() + runtimes_by_name = {default_runtime["name"]: default_runtime} + for runtime in configured_runtimes: + runtimes_by_name[runtime["name"]] = runtime + if runtimes_by_name["JavaSE-21"] is default_runtime and any(runtime.get("default") for runtime in configured_runtimes): + # a configured runtime claims the JDT-LS default; the bundled runtime must not also claim it, + # since JDT-LS expects at most one default runtime + default_runtime["default"] = False + + initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = list(runtimes_by_name.values()) for runtime in initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"]: assert "name" in runtime diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 7e7cd71c..fa05866e 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -644,3 +644,132 @@ class TestIsIgnoredDirname: @pytest.mark.parametrize("dirname", [".git", ".venv", ".idea", ".serena", ".mypy_cache"]) def test_always_ignored_dirs_are_still_ignored(self, jdtls: EclipseJDTLS, dirname: str) -> None: assert jdtls.is_ignored_dirname(dirname) is True + + +# ---------------------------------------------------------------------------- +# _resolve_configured_runtimes / configured `runtimes` initialize settings (#1478) +# ---------------------------------------------------------------------------- + + +def _runtimes(initialize_params: dict) -> list[dict]: + """ + Return the JDT-LS ``java.configuration.runtimes`` list from initialize parameters. + + :param initialize_params: JDTLS initialize-parameter payload. + :return: The configured runtimes list. + """ + return initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] + + +class TestResolveConfiguredRuntimes: + def test_empty_when_unset(self, custom_settings: SolidLSPSettings.CustomLSSettings) -> None: + server = object.__new__(EclipseJDTLS) + server._custom_settings = custom_settings + assert server._resolve_configured_runtimes() == [] + + def test_valid_entry_is_normalized(self, tmp_path: Path) -> None: + jdk = tmp_path / "jdk-25" + jdk.mkdir() + server = object.__new__(EclipseJDTLS) + server._custom_settings = SolidLSPSettings.CustomLSSettings( + {"runtimes": [{"name": "JavaSE-25", "path": str(jdk), "default": True}]} + ) + assert server._resolve_configured_runtimes() == [{"name": "JavaSE-25", "path": str(jdk), "default": True}] + + def test_optional_sources_and_javadoc_pass_through(self, tmp_path: Path) -> None: + jdk = tmp_path / "jdk-25" + jdk.mkdir() + server = object.__new__(EclipseJDTLS) + server._custom_settings = SolidLSPSettings.CustomLSSettings( + {"runtimes": [{"name": "JavaSE-25", "path": str(jdk), "sources": "/src", "javadoc": "/doc"}]} + ) + assert server._resolve_configured_runtimes() == [{"name": "JavaSE-25", "path": str(jdk), "sources": "/src", "javadoc": "/doc"}] + + def test_raises_when_runtimes_is_not_a_list(self) -> None: + server = object.__new__(EclipseJDTLS) + server._custom_settings = SolidLSPSettings.CustomLSSettings({"runtimes": {"name": "JavaSE-25", "path": "/x"}}) + with pytest.raises(ValueError, match="must be a list"): + server._resolve_configured_runtimes() + + @pytest.mark.parametrize( + "entry", [{"path": "/x"}, {"name": "JavaSE-25"}, "not-a-dict", 42], ids=["missing-name", "missing-path", "string", "int"] + ) + def test_raises_for_malformed_entry(self, entry: object) -> None: + server = object.__new__(EclipseJDTLS) + server._custom_settings = SolidLSPSettings.CustomLSSettings({"runtimes": [entry]}) + with pytest.raises(ValueError, match="requires at least a 'name' and a 'path' key"): + server._resolve_configured_runtimes() + + def test_raises_for_nonexistent_path(self, tmp_path: Path) -> None: + server = object.__new__(EclipseJDTLS) + server._custom_settings = SolidLSPSettings.CustomLSSettings( + {"runtimes": [{"name": "JavaSE-25", "path": str(tmp_path / "missing-jdk")}]} + ) + with pytest.raises(FileNotFoundError, match="does not exist"): + server._resolve_configured_runtimes() + + +class TestConfiguredRuntimesInitializeSettings: + def test_bundled_runtime_is_sole_default_when_unconfigured( + self, tmp_path: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + + server = _make_uninitialized_jdtls(repo, {}, runtime_paths) + + assert _runtimes(server._create_base_initialize_params()) == [ + {"name": "JavaSE-21", "path": runtime_paths.jre_home_path, "default": True} + ] + + def test_configured_runtime_extends_bundled_runtime(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + jdk25 = tmp_path / "jdk-25" + jdk25.mkdir() + + server = _make_uninitialized_jdtls(repo, {"runtimes": [{"name": "JavaSE-25", "path": str(jdk25)}]}, runtime_paths) + + runtimes = _runtimes(server._create_base_initialize_params()) + assert {"name": "JavaSE-21", "path": runtime_paths.jre_home_path, "default": True} in runtimes + assert {"name": "JavaSE-25", "path": str(jdk25)} in runtimes + assert len(runtimes) == 2 + + def test_configured_runtime_with_same_name_overrides_bundled_runtime(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + override_jdk = tmp_path / "override-jdk-21" + override_jdk.mkdir() + + server = _make_uninitialized_jdtls(repo, {"runtimes": [{"name": "JavaSE-21", "path": str(override_jdk)}]}, runtime_paths) + + runtimes = _runtimes(server._create_base_initialize_params()) + assert runtimes == [{"name": "JavaSE-21", "path": str(override_jdk)}] + + def test_configured_default_unsets_bundled_default(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + jdk25 = tmp_path / "jdk-25" + jdk25.mkdir() + + server = _make_uninitialized_jdtls(repo, {"runtimes": [{"name": "JavaSE-25", "path": str(jdk25), "default": True}]}, runtime_paths) + + runtimes = _runtimes(server._create_base_initialize_params()) + bundled = next(r for r in runtimes if r["name"] == "JavaSE-21") + configured = next(r for r in runtimes if r["name"] == "JavaSE-25") + assert bundled["default"] is False + assert configured["default"] is True + + def test_invalid_configured_runtime_raises_during_initialize_params(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + + server = _make_uninitialized_jdtls(repo, {"runtimes": [{"name": "JavaSE-25"}]}, runtime_paths) + + with pytest.raises(ValueError, match="requires at least a 'name' and a 'path' key"): + server._create_base_initialize_params() From 61cf7c7d3859f9d2896ad2ca3d665aa93c2f6ba1 Mon Sep 17 00:00:00 2001 From: "Tomi P. Hakala" <tomi.hakala@pobox.fi> Date: Sat, 30 May 2026 13:54:17 +0300 Subject: [PATCH 155/170] Fix gopls replace_symbol_body corrupting type/var/const declarations gopls reports the document-symbol range of single type/var/const declarations starting at the identifier (after the keyword), unlike func declarations whose range includes the func keyword. Serena derives both the displayed body and the replacement range from that range, so the keyword was dropped from both for these declarations and a keyword-inclusive edit corrupted the file (type Foo became type type Foo). Override request_document_symbols in gopls to extend the range start back to the keyword when only the keyword and indentation precede the identifier, recomputing the body so it stays consistent with the replacement range. Funcs, grouped declarations, and selectionRange are left untouched. Adds a unit test for the symbol body and range plus an end-to-end replace_symbol_body round-trip regression test. --- CHANGELOG.md | 5 + src/solidlsp/language_servers/gopls.py | 96 ++++++++++++++++++- .../repos/go/test_repo/symbol_body.go | 26 +++++ .../__snapshots__/test_symbol_editing.ambr | 31 ++++++ test/serena/test_symbol_editing.py | 57 +++++++++++ test/solidlsp/go/test_go_basic.py | 40 ++++++++ 6 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 test/resources/repos/go/test_repo/symbol_body.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b97747..f1227c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ Status of the `main` branch. Changes prior to the next official version change w and other JDK types reported as "cannot be resolved") for projects whose source/target level exceeds the bundled JDK 21 JRE JDT-LS registers by default; configured runtimes extend rather than replace that bundled default. #1478 + - `gopls`: Fix `replace_symbol_body` corrupting single `type`/`var`/`const` declarations by + duplicating the leading keyword (e.g. `type Foo` becoming `type type Foo`). gopls reports the + symbol range of such declarations starting at the identifier rather than the keyword (unlike + `func` declarations); the range is now extended to include the keyword so the body and the + replacement range stay consistent. * Tools: - Fix: `search_for_pattern` marked one line too many as matched whenever a match ended with a line diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 44164927..da80c258 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -2,13 +2,15 @@ import hashlib import json import logging import os +import re import subprocess from collections.abc import Hashable from typing import Any from overrides import override -from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp import ls_types +from solidlsp.ls import DocumentSymbols, LSPFileBuffer, RawDocumentSymbol, SolidLanguageServer, SymbolBodyFactory from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -21,6 +23,12 @@ class Gopls(SolidLanguageServer): Provides Go specific instantiation of the LanguageServer class using gopls. """ + # matches a line prefix that consists solely of a leading `type`/`var`/`const` declaration + # keyword (with optional indentation and the whitespace before the declared identifier). + # gopls reports the symbol range of such single declarations starting at the identifier, + # i.e. after the keyword, whereas `func` declarations include the `func` keyword. + _LEADING_DECL_KEYWORD_RE = re.compile(r"(?P<indent>\s*)(?:type|var|const)\s+") + @classmethod def supports_implementation_request(cls) -> bool: return True @@ -202,6 +210,92 @@ class Gopls(SolidLanguageServer): def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: return symbol["name"].rsplit(".", 1)[-1] + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: + # Override to extend single `type`/`var`/`const` declaration ranges to include the leading + # keyword. gopls excludes the keyword from such ranges (unlike `func` declarations), which + # causes replace_symbol_body to drop the keyword from the symbol body and replacement range; + # a natural keyword-inclusive round-trip edit would then corrupt the file (e.g. `type Foo` + # becomes `type type Foo`). See _extend_go_symbol_range_to_include_leading_keyword. + document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) + if not document_symbols.root_symbols: + return document_symbols + + # obtain the file lines and a body factory to recompute the bodies of extended symbols + with self._open_file_context(relative_file_path, file_buffer, open_in_ls=False) as file_data: + file_lines = file_data.split_lines() + body_factory = SymbolBodyFactory(file_data) + + # extend ranges recursively, operating on copies so the cached symbols are not mutated. + # Children keep their `parent` back-pointer aimed at the original (un-extended) node; + # this is intentional and safe, because ancestor traversal only reads name/kind/overload + # index (see Symbol.iter_ancestors), none of which the extension changes. Rebinding the + # back-pointers would force a deep copy of every extended subtree for no observable gain. + def extend_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation: + extended = self._extend_go_symbol_range_to_include_leading_keyword(symbol, file_lines, body_factory) + children = symbol.get("children") + if children: + if extended is symbol: + extended = symbol.copy() + extended["children"] = [extend_symbol_and_children(child) for child in children] + return extended + + extended_root_symbols = [extend_symbol_and_children(sym) for sym in document_symbols.root_symbols] + + return DocumentSymbols(extended_root_symbols) + + def _extend_go_symbol_range_to_include_leading_keyword( + self, + symbol: ls_types.UnifiedSymbolInformation, + file_lines: list[str], + body_factory: SymbolBodyFactory, + ) -> ls_types.UnifiedSymbolInformation: + """ + Extend a Go symbol's body range to include a leading `type`/`var`/`const` keyword. + + gopls reports the range of a single `type`/`var`/`const` declaration starting at the + declared identifier (after the keyword), whereas the range of a `func` declaration includes + the `func` keyword. This asymmetry makes :meth:`replace_symbol_body` omit the keyword from + both the displayed body and the replacement range, so re-supplying the keyword in an edit + corrupts the file (e.g. ``type Foo`` becomes ``type type Foo``). + + :param symbol: the symbol whose range may be extended. + :param file_lines: the lines of the file in which the symbol is defined. + :param body_factory: the factory used to recompute the symbol body from the extended range. + :return: a copy of the symbol with an extended range, or the original symbol if no leading + keyword precedes the identifier on the start line (e.g. for funcs or for grouped + declarations such as ``var ( ... )`` whose keyword sits on a separate line). + """ + # determine whether only a declaration keyword precedes the identifier on the start line + range_info = symbol["range"] + start_line = range_info["start"]["line"] + start_char = range_info["start"]["character"] + if start_line >= len(file_lines): + return symbol + prefix = file_lines[start_line][:start_char] + match = self._LEADING_DECL_KEYWORD_RE.fullmatch(prefix) + if match is None: + return symbol + + # extend the range start back to the keyword (excluding indentation), updating both the + # symbol range and its location range so the replacement range covers the keyword + new_start = ls_types.Position(line=start_line, character=len(match.group("indent"))) + extended = symbol.copy() + extended["range"] = ls_types.Range(start=new_start, end=range_info["end"]) + location = extended.get("location") + if location: + location = location.copy() + if "range" in location: + location["range"] = ls_types.Range(start=new_start, end=location["range"]["end"]) + extended["location"] = location + + # recompute the body from the now-extended location range so the displayed body stays + # consistent with the replacement range; the stale body must be removed first, since the + # factory returns an existing SymbolBody as-is and otherwise reads the updated location range + extended.pop("body", None) + extended["body"] = body_factory.create_symbol_body(extended) + return extended + def _start_server(self) -> None: """Start gopls server process""" diff --git a/test/resources/repos/go/test_repo/symbol_body.go b/test/resources/repos/go/test_repo/symbol_body.go new file mode 100644 index 00000000..604b98b9 --- /dev/null +++ b/test/resources/repos/go/test_repo/symbol_body.go @@ -0,0 +1,26 @@ +package main + +// BodyStruct is a single type declaration used to verify that replace_symbol_body +// includes the leading `type` keyword in the symbol body and replacement range. +type BodyStruct struct { + Value int +} + +// NamedInt is a single defined-type declaration (non-struct). +type NamedInt int + +// AliasInt is a type alias declaration. +type AliasInt = int + +// GlobalCounter is a single package-level var declaration. +var GlobalCounter int = 0 + +// MaxItems is a single package-level const declaration. +const MaxItems = 100 + +// GroupedA and GroupedB are declared in a grouped var block, where the `var` +// keyword is on a separate line and must NOT be folded into either symbol body. +var ( + GroupedA int = 1 + GroupedB string = "two" +) diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index 7e6c6a8f..aa98a952 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -302,6 +302,37 @@ ''' # --- +# name: test_go_symbol_replacement_no_double_keyword + ''' + package main + + // BodyStruct is a single type declaration used to verify that replace_symbol_body + // includes the leading `type` keyword in the symbol body and replacement range. + type BodyStruct struct { + Value int + } + + // NamedInt is a single defined-type declaration (non-struct). + type NamedInt int64 + + // AliasInt is a type alias declaration. + type AliasInt = int + + // GlobalCounter is a single package-level var declaration. + var GlobalCounter int = 0 + + // MaxItems is a single package-level const declaration. + const MaxItems = 100 + + // GroupedA and GroupedB are declared in a grouped var block, where the `var` + // keyword is on a separate line and must NOT be folded into either symbol body. + var ( + GroupedA int = 1 + GroupedB string = "two" + ) + + ''' +# --- # name: test_insert_in_rel_to_symbol[test_case0-after] ''' """ diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py index 669740fe..030321e2 100644 --- a/test/serena/test_symbol_editing.py +++ b/test/serena/test_symbol_editing.py @@ -6,6 +6,7 @@ Recreate the snapshots with `pytest --snapshot-update`. import logging import os import shutil +import sys import tempfile import time from abc import ABC, abstractmethod @@ -474,6 +475,62 @@ def test_nix_symbol_replacement_no_double_semicolon(snapshot: SnapshotAssertion) test_case.run_test(content_after_ground_truth=snapshot) +# A single `type` declaration body that re-supplies the leading `type` keyword, as a user would +# after copying the body returned by find_symbol. Replacing the body with this must not duplicate +# the keyword. +GO_DECL_REPLACEMENT = """type NamedInt int64""" + + +class GoDeclReplacementTest(EditingTest): + """Test for replacing a single Go ``type``/``var``/``const`` declaration that should NOT result + in a duplicated leading keyword (e.g. ``type type NamedInt``). + """ + + def __init__(self, language: Language, rel_path: str, symbol_name: str, new_body: str): + super().__init__(language, rel_path) + self.symbol_name = symbol_name + self.new_body = new_body + + def _apply_edit(self, code_editor: CodeEditor) -> None: + code_editor.replace_body(self.symbol_name, self.rel_path, self.new_body) + + @overrides + def _test_diff(self, code_diff: CodeDiff, snapshot: SnapshotAssertion | None) -> None: + # the leading declaration keyword must appear exactly once (no `type type` corruption) + for keyword in ("type", "var", "const"): + assert f"{keyword} {keyword} " not in code_diff.modified_content, ( + f"Replacement duplicated the '{keyword}' keyword: {code_diff.modified_content!r}" + ) + return super()._test_diff(code_diff, snapshot) + + +@pytest.mark.go +@pytest.mark.skipif(sys.platform == "win32", reason="gopls is not provisioned on the Windows CI runner") +def test_go_symbol_replacement_no_double_keyword(snapshot: SnapshotAssertion): + """ + Test that replacing a Go ``type``/``var``/``const`` declaration does not duplicate the leading + keyword. + + This exercises the bug where: + - Original: type NamedInt int + - Replacement body (as displayed by find_symbol): type NamedInt int64 + - Bug result would be: type type NamedInt int64 (the original `type ` prefix is kept because the + gopls symbol range starts at the identifier, and the supplied body adds another `type`) + - Correct result should be: type NamedInt int64 + + gopls reports the symbol range of such declarations starting at the identifier (after the + keyword), unlike ``func`` declarations whose range includes the ``func`` keyword. The gopls + range extension prevents the duplicated keyword. + """ + test_case = GoDeclReplacementTest( + Language.GO, + "symbol_body.go", + "NamedInt", + GO_DECL_REPLACEMENT, + ) + test_case.run_test(content_after_ground_truth=snapshot) + + class RenameSymbolTest(EditingTest): def __init__(self, language: Language, rel_path: str, symbol_name: str, new_name: str): super().__init__(language, rel_path) diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py index 347f6022..2388b78c 100644 --- a/test/solidlsp/go/test_go_basic.py +++ b/test/solidlsp/go/test_go_basic.py @@ -48,6 +48,46 @@ class TestGoLanguageServer: refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go" + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_type_var_const_body_includes_leading_keyword(self, language_server: SolidLanguageServer) -> None: + """ + Single ``type``/``var``/``const`` declarations must expose a body and replacement range that + include the leading keyword, just like ``func`` declarations do. + + Regression test for gopls reporting the symbol range of such declarations starting at the + declared identifier (after the keyword) rather than at the keyword. That asymmetry made + replace_symbol_body drop the keyword from the body and replacement range, so a natural + keyword-inclusive round-trip edit corrupted the file (e.g. ``type Foo`` -> ``type type Foo``). + """ + all_symbols, _ = language_server.request_document_symbols("symbol_body.go").get_all_symbols_and_roots() + symbols_by_name = {sym.get("name"): sym for sym in all_symbols} + + # single declarations: body starts with the keyword, the range start moves to the keyword + # (column 0 here), and the selection range still points at the identifier after the keyword + expected_keyword_by_name = { + "BodyStruct": "type ", + "NamedInt": "type ", + "AliasInt": "type ", + "GlobalCounter": "var ", + "MaxItems": "const ", + } + for name, keyword in expected_keyword_by_name.items(): + sym = symbols_by_name.get(name) + assert sym is not None, f"{name} not found in symbol_body.go" + body = sym["body"].get_text() + assert body.startswith(keyword), f"Expected body of {name} to start with {keyword!r}, got {body[:24]!r}" + assert sym["location"]["range"]["start"]["character"] == 0, f"Expected {name} body range to start at the keyword (col 0)" + assert sym["selectionRange"]["start"]["character"] > 0, f"Expected {name} selectionRange to point at the identifier" + + # grouped declarations keep the keyword on a separate line (e.g. ``var ( ... )``), so their + # bodies must NOT include it and their ranges must be left untouched + for name in ("GroupedA", "GroupedB"): + sym = symbols_by_name.get(name) + assert sym is not None, f"{name} not found in symbol_body.go" + body = sym["body"].get_text() + assert body.startswith(name), f"Expected grouped var {name} body to start with the identifier, got {body[:24]!r}" + assert not body.startswith("var"), f"Grouped var {name} body must not include the 'var' keyword" + if language_has_verified_implementation_support(Language.GO): @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) From 4dc109281fc593686465c7daaed6a283a2e6af07 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Sun, 19 Jul 2026 16:25:51 +0200 Subject: [PATCH 156/170] Gopls: Adjust cache fingerprint to account for implementation changes simplifying the fingerprint logic --- src/solidlsp/language_servers/gopls.py | 27 +++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index da80c258..aa29ca16 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -170,11 +170,11 @@ class Gopls(SolidLanguageServer): @override def _document_symbols_cache_fingerprint(self) -> Hashable: """ - Compute a deterministic fingerprint of the Go build context. - - The fingerprint includes gopls_settings and selected env vars that affect symbol discovery. + Compute a deterministic fingerprint of method/implementation versions and Gopls settings/the Go build context. """ normalize_symbol_name_version = 1 + request_document_symbols_impl_version = 2 + gopls_settings_raw = self._custom_settings.settings.get("gopls_settings") gopls_settings: dict | None @@ -193,18 +193,17 @@ class Gopls(SolidLanguageServer): # Version processed symbols even when the build context itself is empty. if gopls_settings is None and not env_subset: - return normalize_symbol_name_version + additional_settings_fingerprint = "" + else: + fingerprint_data: dict[str, object] = { + "env": env_subset, + } + if gopls_settings is not None: + fingerprint_data["gopls_settings"] = gopls_settings + canonical_json = self._canonical_json_or_raise(json, fingerprint_data) + additional_settings_fingerprint = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16] - fingerprint_data: dict[str, object] = { - "env": env_subset, - "normalize_symbol_name_version": normalize_symbol_name_version, - } - if gopls_settings is not None: - fingerprint_data["gopls_settings"] = gopls_settings - - canonical_json = self._canonical_json_or_raise(json, fingerprint_data) - - return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16] + return normalize_symbol_name_version, request_document_symbols_impl_version, additional_settings_fingerprint @override def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: From 4379b4e58a5033a7f969f021c793fc7a00ef46a3 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Sun, 19 Jul 2026 15:58:14 +0200 Subject: [PATCH 157/170] Increase robustness of SerenaAgent tests * Do not unnecessarily call apply_ex instead of apply (timeout can occur) * Increase tool timeout to 10 minutes (for slow CI cases) * TestPromptProvision: Raise exceptions in apply_ex (such that outputs don't inadvertently check against exception messages) Resolves #1695 --- src/serena/config/serena_config.py | 3 +++ test/serena/test_serena_agent.py | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 50d48aa5..e04b1ac7 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -873,6 +873,9 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): JetBrains IDE launch command, which can be used to auto-start an IDE instance on demand. """ tool_timeout: float = DEFAULT_TOOL_TIMEOUT + """ + timeout for tool calls in seconds; if a tool takes longer than this, it is aborted and an error is returned. + """ token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name """Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics. diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 2ee299b3..1199ebd3 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -764,7 +764,7 @@ SAFE_DELETE_SUCCEEDS_CASES = [ @pytest.fixture def serena_config(): - config = SerenaConfig(log_level=logging.ERROR).with_headless_mode_overrides() + config = SerenaConfig(log_level=logging.ERROR, tool_timeout=600).with_headless_mode_overrides() # Create test projects for all supported languages test_projects = [] @@ -1047,7 +1047,7 @@ class TestSerenaAgent: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply_ex( + result = find_symbol_tool.apply( name_path_pattern=case.name_path, depth=0, relative_path=None, @@ -1070,7 +1070,7 @@ class TestSerenaAgent: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply_ex( + result = find_symbol_tool.apply( name_path_pattern=case.name_path, depth=0, substring_matching=True, @@ -1088,7 +1088,7 @@ class TestSerenaAgent: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply_ex( + result = find_symbol_tool.apply( name_path_pattern=case.name_path, depth=0, substring_matching=False, @@ -1342,7 +1342,7 @@ class TestPromptProvision: @classmethod def _call_tool(cls, agent: SerenaAgent, tool_class: type[Tool], session_id: str = "global", **kwargs) -> str: - result = agent.get_tool(tool_class).apply_ex(mcp_ctx=cls.MockContext(session_id), **kwargs) + result = agent.get_tool(tool_class).apply_ex(mcp_ctx=cls.MockContext(session_id), catch_exceptions=False, **kwargs) return result @staticmethod From f5824231bed14c36fb4f3eb2b9f0ed3a4eabbe43 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Sun, 19 Jul 2026 20:12:38 +0200 Subject: [PATCH 158/170] Fix: Properly differentiate between raw and high-level symbol cache fingerprints, avoiding unnecessary invalidations of the raw cache when only the derived high-level representation changes Fixes #1722 --- CHANGELOG.md | 2 + .../clangd_language_server.py | 2 +- src/solidlsp/language_servers/gopls.py | 12 ++-- src/solidlsp/ls.py | 62 ++++++++++++++----- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1227c72..5e0722fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Status of the `main` branch. Changes prior to the next official version change w rather than guessing at a body that could be wrong #1498 * Language Servers: + - Fix: Properly differentiate between raw and high-level symbol cache fingerprints, avoiding unnecessary + invalidations of the raw cache when only the derived high-level representation changes - Java (JDT-LS): add `runtimes` to `ls_specific_settings.java`, a list of extra JRE/JDK entries (`name`, `path`, optional `default`/`sources`/`javadoc`) passed through to JDT-LS's `java.configuration.runtimes`. Fixes silently broken JDK type resolution (`java.lang.Object` diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 4db49125..4633298f 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -78,7 +78,7 @@ class ClangdLanguageServer(SolidLanguageServer): self.resolve_main_method_available = threading.Event() @override - def _document_symbols_cache_fingerprint(self) -> Hashable: + def _raw_document_symbols_cache_fingerprint(self) -> Hashable: cache_format_version = 1 cpp_settings = self._custom_settings or {} return ( diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index aa29ca16..8ebd7f38 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -169,12 +169,12 @@ class Gopls(SolidLanguageServer): @override def _document_symbols_cache_fingerprint(self) -> Hashable: - """ - Compute a deterministic fingerprint of method/implementation versions and Gopls settings/the Go build context. - """ normalize_symbol_name_version = 1 request_document_symbols_impl_version = 2 + return normalize_symbol_name_version, request_document_symbols_impl_version + @override + def _raw_document_symbols_cache_fingerprint(self) -> Hashable: gopls_settings_raw = self._custom_settings.settings.get("gopls_settings") gopls_settings: dict | None @@ -193,7 +193,7 @@ class Gopls(SolidLanguageServer): # Version processed symbols even when the build context itself is empty. if gopls_settings is None and not env_subset: - additional_settings_fingerprint = "" + return None else: fingerprint_data: dict[str, object] = { "env": env_subset, @@ -201,9 +201,7 @@ class Gopls(SolidLanguageServer): if gopls_settings is not None: fingerprint_data["gopls_settings"] = gopls_settings canonical_json = self._canonical_json_or_raise(json, fingerprint_data) - additional_settings_fingerprint = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16] - - return normalize_symbol_name_version, request_document_symbols_impl_version, additional_settings_fingerprint + return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16] @override def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 8b05a32f..820d241d 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -324,6 +324,12 @@ class SolidLanguageServer(ABC): RAW_DOCUMENT_SYMBOL_CACHE_FILENAME = "raw_document_symbols.pkl" RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK = "document_symbols_cache_v23-06-25.pkl" DOCUMENT_SYMBOL_CACHE_VERSION = 4 + """ + defines the version of the high-level document symbol format. + This should be incremented whenever there is a change in the way document symbols are stored. + For changes that affect the symbols of a particular language server implementation (subclass), + change :meth:`_document_symbols_cache_fingerprint` instead. + """ DOCUMENT_SYMBOL_CACHE_FILENAME = "document_symbols.pkl" # Directories that should always be ignored regardless of language: @@ -2891,13 +2897,14 @@ class SolidLanguageServer(ABC): Returns a fingerprint of any language server-specific aspects that result in changes to the high-level document symbol information. - Language servers must implement this method/change the return value - * whenever they change the `request_document_symbols` implementation to modify the returned content - * are reconfigured in a way that affects the returned contents (e.g. context-specific configuration - such as build flags or environment variables); configuration options can, in such cases, be - hashed together to produce a single fingerprint value. + Language servers must implement this method/change the return value if + the `request_document_symbols` implementation (or any of the methods called by it) + are changed to modify the returned content. - Whenever the value changes, the document symbols cache will be invalidated and re-populated. + Whenever the value changes, the high-level document symbols cache will be invalidated and re-populated. + + Note: For aspects that change the values returned by the language server itself, + change `_raw_document_symbols_cache_fingerprint` instead. The value must be hashable and safe for inclusion in cache version tuples. E.g. use an integer, a string or a tuple of integers/strings. @@ -2912,12 +2919,16 @@ class SolidLanguageServer(ABC): """ Return the version for the document symbols cache. - Incorporates cache context fingerprint if provided by the language server. + Incorporates cache fingerprints if provided by the language server. """ - fingerprint = self._document_symbols_cache_fingerprint() - if fingerprint is not None: - return (self.DOCUMENT_SYMBOL_CACHE_VERSION, fingerprint) - return self.DOCUMENT_SYMBOL_CACHE_VERSION + version: list[Hashable] = [self.DOCUMENT_SYMBOL_CACHE_VERSION] + high_level_fingerprint = self._document_symbols_cache_fingerprint() + if high_level_fingerprint is not None: + version.append(high_level_fingerprint) + raw_fingerprint = self._raw_document_symbols_cache_fingerprint() + if raw_fingerprint is not None: + version.append(raw_fingerprint) + return version[0] if len(version) == 1 else tuple(version) def _save_raw_document_symbols_cache(self) -> None: cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME @@ -2937,9 +2948,32 @@ class SolidLanguageServer(ABC): e, ) - def _raw_document_symbols_cache_version(self) -> tuple[Hashable, ...]: - base_version: tuple[Hashable, ...] = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version) - fingerprint = self._document_symbols_cache_fingerprint() + def _raw_document_symbols_cache_fingerprint(self) -> Hashable | None: + """ + Returns a fingerprint of any language server-specific aspects that result in changes + to the raw document symbol information produced by the language server. + + Language servers must implement this method/change the return value whenever they + are reconfigured or otherwise affected in a way that affects the contents returned + by the language server for document symbol requests (raw request result). + For example, this could be context-specific configuration such as build flags or environment variables; + configuration options can, in such cases, be hashed together to produce a single fingerprint value. + + Whenever the value changes, the raw document symbols cache (as well as the high-level cache that + builds on the raw results) will be invalidated and re-populated. + + The value must be hashable and safe for inclusion in cache version tuples. + E.g. use an integer, a string or a tuple of integers/strings. + + For example, if there is a single aspect being considered, use an integer to reflect the version + of this aspect (incrementing it whenever the implementation changes). + If multiple versioned aspects exist, use a tuple of versions, etc. + """ + return None + + def _raw_document_symbols_cache_version(self) -> Hashable: + base_version = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version) + fingerprint = self._raw_document_symbols_cache_fingerprint() if fingerprint is not None: return (*base_version, fingerprint) return base_version From 5c85d961a0b32b98f2954508adf9dbccb655910e Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Sun, 19 Jul 2026 21:47:41 +0200 Subject: [PATCH 159/170] LanguageServerDependencyProviderUvx: Use 'uv tool run' instead of 'uv x' (more widely supported) Resolves #1721 --- CHANGELOG.md | 2 ++ src/solidlsp/dependency_provider.py | 9 ++------- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0722fb..8a7a0a0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Status of the `main` branch. Changes prior to the next official version change w * Language Servers: - Fix: Properly differentiate between raw and high-level symbol cache fingerprints, avoiding unnecessary invalidations of the raw cache when only the derived high-level representation changes + - Improve uv-based language server launch command compatibility: Use the more widely supported + `uv tool run` instead of `uv x` #1721 - Java (JDT-LS): add `runtimes` to `ls_specific_settings.java`, a list of extra JRE/JDK entries (`name`, `path`, optional `default`/`sources`/`javadoc`) passed through to JDT-LS's `java.configuration.runtimes`. Fixes silently broken JDK type resolution (`java.lang.Object` diff --git a/src/solidlsp/dependency_provider.py b/src/solidlsp/dependency_provider.py index d3826eba..386e14d5 100644 --- a/src/solidlsp/dependency_provider.py +++ b/src/solidlsp/dependency_provider.py @@ -214,12 +214,7 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCo entrypoint: 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. + """Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv tool run``. :param package: PyPI package name (e.g. ``"pyright"``). :param version: Pinned package version. @@ -234,7 +229,7 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCo uv_path = shutil.which("uv") if uv_path is not None: - return [uv_path, "x", *base_args] + return [uv_path, "tool", "run", *base_args] # `uv tool run` is the same as `uvx` raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).") From f631a4bceb40dd7b38151f2136473035cbfb7820 Mon Sep 17 00:00:00 2001 From: Vasil Boychev <vasil.boychev@bulcode.com> Date: Mon, 20 Jul 2026 12:47:29 +0300 Subject: [PATCH 160/170] PHP: expose file_filter via ls_specific_settings (#1710) (#1711) - PHP/Intelephense: expose `file_filter` via `ls_specific_settings["php"]`, so that additional extensions containing PHP sources (e.g. Drupal's `.module` / `.install` / `.inc` / `.theme`) become visible to the symbol tools and are indexed by the language server - PHP: treat `.phtml` files as PHP sources by default (all PHP language servers) --- CHANGELOG.md | 6 ++ docs/02-usage/050_configuration.md | 13 ++++ src/solidlsp/language_servers/intelephense.py | 24 +++++- src/solidlsp/ls_config.py | 10 ++- .../repos/php/test_repo/drupal_module.module | 29 +++++++ test/solidlsp/php/test_php_config.py | 77 +++++++++++++++++++ 6 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 test/resources/repos/php/test_repo/drupal_module.module create mode 100644 test/solidlsp/php/test_php_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7a0a0f..4b65805d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,12 @@ Status of the `main` branch. Changes prior to the next official version change w it fits, otherwise truncated with a trailing '...' and a note) before falling back to bare line numbers, so agents can pick the right match without re-reading files. #1640 +* Language Servers: + - PHP: treat `.phtml` files as PHP sources by default (all PHP language servers) #1710 + - PHP/Intelephense: expose `file_filter` via `ls_specific_settings["php"]`, so that additional + extensions containing PHP sources (e.g. Drupal's `.module` / `.install` / `.inc` / `.theme`) + become visible to the symbol tools and are indexed by the language server #1710 + * JetBrains: - Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be - read via `ReadFileTool` diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 469ef566..94666266 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -931,6 +931,19 @@ Supported settings: | `ignore_vendor` | `true` | Ignore directories named `vendor` while indexing the project. | | `maxFileSize` | unset | Forwarded as `intelephense.files.maxSize` in `initializationOptions`. | | `maxMemory` | unset | Forwarded as `intelephense.maxMemory` in `initializationOptions`. | +| `file_filter` | unset | Additional file extensions (with leading dot) to treat as PHP sources, e.g. `[".module", ".install"]`; added to the defaults `.php` / `.phtml` (#1710). | + +Example configuration making Drupal source files visible to the symbol tools: + +```yaml +ls_specific_settings: + php: + file_filter: [".module", ".install", ".inc", ".theme", ".profile", ".engine"] +``` + +Notes: +- Extensions added via `file_filter` are synced into Serena's PHP source-file matcher and pushed to Intelephense as `intelephense.files.associations` globs at startup, so `find_symbol` and the language server treat the same files as PHP sources. +- The matcher is reset on every language server activation, so one project's `file_filter` does not leak into another. #### PHP (`Phpactor`) diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index 6f68c33e..1f937da3 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -12,7 +12,7 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, DidChangeConfigurationParams, LocationLink from solidlsp.settings import SolidLSPSettings from ..lsp_protocol_handler import lsp_types @@ -35,6 +35,11 @@ class Intelephense(SolidLanguageServer): - maxMemory: sets intelephense.maxMemory - maxFileSize: sets intelephense.files.maxSize - ignore_vendor: whether or ignore directories named "vendor" (default: true) + - file_filter: list of additional file extensions (with leading dot) to treat as PHP + sources, e.g. [".module", ".inc"]. The extensions are added to the defaults (".php", + ".phtml") of Serena's PHP source-file matcher and are pushed to Intelephense via + files.associations, such that the symbol tools and the language server treat the + same files as PHP sources (see #1710). """ @override @@ -106,6 +111,12 @@ class Intelephense(SolidLanguageServer): self._ignored_dirnames.add("vendor") log.info(f"Ignoring the following directories for PHP projects: {', '.join(sorted(self._ignored_dirnames))}") + # extend the source-file matcher with any additional extensions configured via `file_filter`, + # such that Serena's symbol tools treat the added extensions as PHP sources as well (#1710) + file_filter = self._custom_settings.get("file_filter") + if file_filter: + self.language.get_source_fn_matcher().add_extensions(*file_filter) + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @@ -184,6 +195,17 @@ class Intelephense(SolidLanguageServer): self.server.notify.initialized({}) + # push the file associations derived from the source-file matcher (the default extensions + # plus any configured via `file_filter`), such that Intelephense indexes the same set of + # files that Serena's symbol tools treat as PHP sources (#1710). This cannot be passed in + # the initialize request: Intelephense reads configuration exclusively from + # workspace/didChangeConfiguration (its initializationOptions only cover storagePath, + # globalStoragePath, clearCache and licenceKey). + associations = [f"*{ext}" for ext in self.language.get_source_fn_matcher().file_extensions] + intelephense_config: DidChangeConfigurationParams = {"settings": {"intelephense": {"files": {"associations": associations}}}} + log.info(f"Sending workspace/didChangeConfiguration with file associations: {associations}") + self.server.notify.workspace_did_change_configuration(intelephense_config) + # Intelephense server is typically ready immediately after initialization # TODO: This is probably incorrect; the server does send an initialized notification, which we could wait for! diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 2aa87fdc..f5b6edea 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -42,6 +42,13 @@ class FilenameMatcher: """ self._file_extensions = list(self._initial_file_extensions) + @property + def file_extensions(self) -> list[str]: + """The file extensions currently registered with this matcher, including any added via + :meth:`add_extensions`. Returned as a copy. + """ + return list(self._file_extensions) + def add_extensions(self, *file_extensions: str) -> None: """ Add further file extensions to this matcher (idempotent). @@ -448,7 +455,8 @@ class Language(str, Enum): case self.DART: return FilenameMatcher(".dart") case self.PHP | self.PHP_PHPACTOR | self.PHP_PHPANTOM: - return FilenameMatcher(".php") + # .phtml is a standard (yet outdated) extension for PHP sources + return FilenameMatcher(".php", ".phtml") case self.R: return FilenameMatcher(".R", ".r", ".Rmd", ".Rnw") case self.PERL: diff --git a/test/resources/repos/php/test_repo/drupal_module.module b/test/resources/repos/php/test_repo/drupal_module.module new file mode 100644 index 00000000..02bc7189 --- /dev/null +++ b/test/resources/repos/php/test_repo/drupal_module.module @@ -0,0 +1,29 @@ +<?php + +/** + * @file + * Drupal-style module file: ordinary PHP kept in a non-.php extension. + * + * Only visible to the symbol tools when ls_specific_settings["php"]["file_filter"] includes + * ".module" (see #1710); inert for all tests running with default settings. + */ + +/** + * Implements hook_help(). + */ +function drupal_module_help($route_name) { + return 'Help for ' . $route_name; +} + +/** + * Implements hook_cron(). + */ +function drupal_module_cron() { + helperFunction(); +} + +class DrupalModuleController { + public function build(): string { + return drupal_module_help('help.page.drupal_module'); + } +} diff --git a/test/solidlsp/php/test_php_config.py b/test/solidlsp/php/test_php_config.py new file mode 100644 index 00000000..953b4423 --- /dev/null +++ b/test/solidlsp/php/test_php_config.py @@ -0,0 +1,77 @@ +"""Configuration plumbing for ``Intelephense``'s ``file_filter``. + +Serena treats only ``.php`` / ``.phtml`` files as PHP sources by default, while Drupal projects +keep ordinary PHP in ``.module`` / ``.install`` / ``.inc`` / ``.theme`` / ``.profile`` / +``.engine`` files, which therefore stay invisible to ``find_symbol`` and friends. +``ls_specific_settings["php"]`` accepts a ``file_filter`` key with additional extensions, +mirroring the Perl mechanism from #1449 / #1642 (see #1710). + +The unit tests cover the default source-file matcher. The configuration plumbing itself lives +inline in ``Intelephense.__init__`` (matcher sync) and ``_start_server`` (the +``intelephense.files.associations`` push) and is exercised end-to-end by the integration test +at the bottom, which starts a real Intelephense and is gated by the ``php`` marker like the +rest of the PHP suite. +""" + +import pytest + +from solidlsp.ls_config import FilenameMatcher, Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import get_repo_path, start_ls_context + + +class TestPhpSourceFnMatcherDefaults: + def test_phtml_matched_by_default_for_all_php_language_servers(self) -> None: + # .phtml is a standard (yet outdated) PHP extension, so all PHP language servers treat it + # as a PHP source by default (#1710). + for language in (Language.PHP, Language.PHP_PHPACTOR, Language.PHP_PHPANTOM): + matcher = language.get_source_fn_matcher() + assert matcher.is_relevant_filename("index.php"), f"{language}: .php not matched" + assert matcher.is_relevant_filename("template.phtml"), f"{language}: .phtml not matched" + + def test_module_not_matched_by_default(self) -> None: + # guard for the integration test below: .module files only become visible via file_filter + assert not Language.PHP.get_source_fn_matcher().is_relevant_filename("hooks.module") + + def test_file_extensions_property_returns_copy(self) -> None: + # _create_base_initialize_params derives the files.associations globs from this property; + # mutating the returned list must not affect the matcher. + matcher = FilenameMatcher(".php", ".phtml") + extensions = matcher.file_extensions + extensions.append(".module") + + assert ".module" not in matcher.file_extensions + assert not matcher.is_relevant_filename("hooks.module") + + +@pytest.mark.php +class TestFileFilterIntegration: + """End-to-end check that a custom ``file_filter`` makes a Drupal-style file visible. + + Starts a real Intelephense, hence gated by the ``php`` marker. The ``drupal_module.module`` + fixture stays invisible to every test that runs with default settings. + """ + + def test_module_file_symbols_and_references_visible(self) -> None: + with start_ls_context( + Language.PHP, + ls_specific_settings={Language.PHP: {"file_filter": [".module"]}}, + ) as ls: + # Layer 2 (files.associations) must be asserted FIRST: the reference in the + # never-opened drupal_module.module can only come from the server's association-driven + # background index. request_full_symbol_tree below didOpens every matched file in the + # LS, after which this assertion could pass even without the associations. + helper_php_path = str(get_repo_path(Language.PHP) / "helper.php") + references = ls.request_references(helper_php_path, 2, len("function ")) + assert any(ref["uri"].endswith("drupal_module.module") for ref in references), ( + f"helperFunction call in drupal_module.module not found in references: {references}" + ) + + # Layer 1 (Serena's source matcher): the .module file takes part in symbol traversal. + symbols = ls.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "drupal_module_help"), ( + "drupal_module_help from drupal_module.module not found in the symbol tree" + ) + assert SymbolUtils.symbol_tree_contains_name(symbols, "DrupalModuleController"), ( + "DrupalModuleController from drupal_module.module not found in the symbol tree" + ) From 16e8e24da103d47beb5f43df1f5585985f9aabd6 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 20 Jul 2026 22:16:42 +0200 Subject: [PATCH 161/170] Fix: Language servers were not notified of external file system changes, causing some symbolic operations (such as `find_referencing_symbols`) to report stale information. An explicit file system synchronisation mechanism is now used to detect changes prior to the affected tool executions. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note that tools like `find_symbol` and `get_symbols_overview` do not need the synchronisation, because they explicitly open all the relevant files themselves. Resolves #1718 Co-authored-by: Niccolò Franzin <niccolo@cognitivext.com> --- CHANGELOG.md | 4 ++ src/serena/ls_manager.py | 118 +++++++++++++++++++++++++++++-- src/serena/project.py | 10 ++- src/serena/tools/symbol_tools.py | 14 +++- test/serena/test_ls_file_sync.py | 92 ++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 test/serena/test_ls_file_sync.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7a0a0f..f2995eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Status of the `main` branch. Changes prior to the next official version change w * Language Servers: - Fix: Properly differentiate between raw and high-level symbol cache fingerprints, avoiding unnecessary invalidations of the raw cache when only the derived high-level representation changes + - Fix: Language servers were not notified of external file system changes, causing some + symbolic operations (such as `find_referencing_symbols`) to report stale information. + An explicit file system polling mechanism is now used to detect changes prior to the affected + tool executions. - Improve uv-based language server launch command compatibility: Use the more widely supported `uv tool run` instead of `uv x` #1721 - Java (JDT-LS): add `runtimes` to `ls_specific_settings.java`, a list of extra JRE/JDK entries diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 3b44a9af..27218ded 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -2,14 +2,20 @@ import logging import os.path import threading from collections.abc import Iterator +from pathlib import Path +from typing import TYPE_CHECKING from sensai.util.logging import LogTime from serena.config.serena_config import ProjectConfig, SerenaPaths from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import DidChangeWatchedFilesParams, FileChangeType, FileEvent from solidlsp.settings import SolidLSPSettings +if TYPE_CHECKING: + from .project import Project + log = logging.getLogger(__name__) @@ -68,9 +74,7 @@ class LanguageServerManager: """ def __init__( - self, - language_servers: dict[Language, SolidLanguageServer], - language_server_factory: LanguageServerFactory | None = None, + self, language_servers: dict[Language, SolidLanguageServer], language_server_factory: LanguageServerFactory, project: "Project" ) -> None: """ :param language_servers: a mapping from language to language server; the servers are assumed to be already started. @@ -81,6 +85,7 @@ class LanguageServerManager: """ self._language_servers = language_servers self._language_server_factory = language_server_factory + self._file_change_notifier = LanguageServerFileChangeNotifier(project, self) @property def _default_language_server(self) -> SolidLanguageServer: @@ -89,13 +94,14 @@ class LanguageServerManager: return next(iter(self._language_servers.values())) @staticmethod - def from_languages(languages: list[Language], factory: LanguageServerFactory) -> "LanguageServerManager": + def from_languages(languages: list[Language], factory: LanguageServerFactory, project: "Project") -> "LanguageServerManager": """ Creates a manager with language servers for the given languages using the given factory. The language servers are started in parallel threads. :param languages: the languages for which to spawn language servers :param factory: the factory for language server creation + :param project: the project for which the language servers are created :return: the instance """ @@ -145,7 +151,7 @@ class LanguageServerManager: failure_messages = "\n".join([f"{lang.value}: {e}" for lang, e in exceptions.items()]) raise LanguageServerManagerInitialisationError(f"Failed to start {len(exceptions)} language server(s):\n{failure_messages}") - return LanguageServerManager(language_servers, factory) + return LanguageServerManager(language_servers, factory, project) def _ensure_functional_ls(self, ls: SolidLanguageServer) -> SolidLanguageServer: if not ls.is_running(): @@ -254,3 +260,105 @@ class LanguageServerManager: def has_suitable_ls_for_file(self, relative_file_path: str) -> bool: return self._get_suitable_language_server(relative_file_path) is not None + + def sync_file_system_changes(self) -> int: + """ + Polls the file system for changes to source files and notifies the language servers of any changes + (particularly changes that happened outside of Serena's own file tools, which are not covered by + the notifications sent by those tools/CodeEditor). + + :return: the number of individual file change events detected (0 if nothing changed). + """ + log.info("Polling file system for changes to source files ...") + num_changes = self._file_change_notifier.poll_and_notify() + log.info(f"File system polling complete; {num_changes} change events sent to language servers.") + return num_changes + + +class LanguageServerFileChangeNotifier: + """ + Detects changes to source files on disk and notifies language servers of those changes. + """ + + def __init__(self, project: "Project", language_server_manager: LanguageServerManager) -> None: + self._project = project + self._language_server_manager = language_server_manager + self._freshness_last_seen_mtimes: dict[str, float] | None = None + self._freshness_lock = threading.Lock() + + def poll_and_notify(self) -> int: + """ + Detects source files that were changed, created or deleted on disk since the last call + and notifies every language server managed for this project via the LSP + ``workspace/didChangeWatchedFiles`` notification. + + This exists because Serena's own file and symbol tools notify the language server inline + (via didOpen/didChange/didClose) when they edit a file, but edits made through any other + channel (another editor, a second agent, a git checkout, a build step) are otherwise + invisible to a warm language server, causing symbolic queries to answer from a stale index. + + The set of files considered is exactly the set Serena itself tracks (see + :meth:`gather_source_files`), so no separate file-discovery logic has to be kept in sync. + The dominant cost is the directory walk plus one ``os.stat`` per tracked file; this is + intended to be called before symbolic tool invocations rather than on a timer. + + :return: the number of change events sent (0 if nothing changed, if no language server is + running yet, or on the first call, which only establishes the baseline). + """ + current: dict[str, float] = {} + for rel_path in self._project.gather_source_files(): + try: + current[rel_path] = os.stat(os.path.join(self._project.project_root, rel_path)).st_mtime + except OSError: + continue + + # Read-diff-swap under the lock only; the filesystem walk above and the LSP notifications + # below stay outside it so concurrent callers do not serialize on I/O. + with self._freshness_lock: + previous = self._freshness_last_seen_mtimes + self._freshness_last_seen_mtimes = current + + if previous is None: + return 0 + + # compute the set of individual events (created, changed, deleted) + events: list[tuple[str, FileChangeType]] = [] + for rel_path, mtime in current.items(): + prev_mtime = previous.get(rel_path) + if prev_mtime is None: + events.append((rel_path, FileChangeType.Created)) + elif mtime > prev_mtime: + events.append((rel_path, FileChangeType.Changed)) + events.extend((rel_path, FileChangeType.Deleted) for rel_path in previous if rel_path not in current) + + if not events: + return 0 + + # create the change didChangeWatchedFiles notification + changes: list[FileEvent] = [ + {"uri": Path(self._project.project_root, rel_path).resolve().as_uri(), "type": change_type} for rel_path, change_type in events + ] + params: DidChangeWatchedFilesParams = {"changes": changes} + created_paths = [rel_path for rel_path, change_type in events if change_type == FileChangeType.Created] + + for ls in self._language_server_manager.iter_language_servers(): + # send the didChangeWatchedFiles notification to the language server + try: + ls.server.notify.did_change_watched_files(params) + except Exception as e: + log.error("Failed to notify language server of watched file changes", exc_info=e) + + # A didChangeWatchedFiles(Created) notification alone is not enough for every backend + # (observed with pyright) to fold a brand-new file into its cross-file reference graph; + # an open/close cycle forces the parse+bind that Serena's own file tools trigger via + # SolidLanguageServer.open_file(). + for rel_path in created_paths: + if ls.is_ignored_path(rel_path, ignore_unsupported_files=True): + continue + try: + with ls.open_file(rel_path): + pass + except Exception as e: + log.error(f"Failed to refresh newly created file {rel_path!r} in language server", exc_info=e) + + return len(events) diff --git a/src/serena/project.py b/src/serena/project.py index 9cb183d9..e316f7a2 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -493,7 +493,7 @@ class Project(ToStringMixin): ls_specific_settings=ls_specific_settings, trace_lsp_communication=self.serena_config.trace_lsp_communication, ) - self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory) + self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory, self) return self.language_server_manager except Exception as e: self._language_server_manager_init_error = e @@ -557,6 +557,14 @@ class Project(ToStringMixin): log.info("Removing and stopping the language server for language %s ...", language.value) self.language_server_manager.remove_language_server(language) + def ls_sync_file_system_changes(self) -> int: + """ + Synchronizes file system changes with the project's associated language server(s), if applicable + """ + if self.language_server_manager: + return self.language_server_manager.sync_file_system_changes() + return 0 + def shutdown(self, timeout: float = 2.0) -> None: if self.language_server_manager is not None: self.language_server_manager.stop_all(save_cache=True, timeout=timeout) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index a0e5fc10..f79333e4 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -274,11 +274,13 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): :param max_answer_chars: max result length; -1 for default :return: a list of JSON objects with the symbols referencing the requested symbol """ + self.project.ls_sync_file_system_changes() + include_body = False # It is probably never a good idea to include the body of the referencing symbols parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None - symbol_retriever = self.create_language_server_symbol_retriever() + symbol_retriever = self.create_language_server_symbol_retriever() references_in_symbols = symbol_retriever.find_referencing_symbols( name_path, relative_file_path=relative_path, @@ -361,6 +363,8 @@ class FindImplementationsTool(Tool, ToolMarkerSymbolicRead): :param max_answer_chars: max result length; -1 for default :return: a list of JSON objects with the symbols implementing the requested symbol """ + self.project.ls_sync_file_system_changes() + include_body = False parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None @@ -414,6 +418,8 @@ class FindDeclarationTool(Tool, ToolMarkerSymbolicRead): :param include_body: whether to include the symbol's body in the result. Default False. :param include_info: whether to include additional info (hover-like). Default False. """ + self.project.ls_sync_file_system_changes() + symbol_retriever = self.create_language_server_symbol_retriever() relative_path = self._sanitize_input_param(relative_path) regex = self._sanitize_input_param(regex) @@ -496,6 +502,8 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): :param max_answer_chars: max result length; -1 for default :return: grouped diagnostics for the requested file. """ + self.project.ls_sync_file_system_changes() + symbol_retriever = self.create_language_server_symbol_retriever() diagnostics = symbol_retriever.get_file_diagnostics( relative_file_path=relative_path, @@ -547,6 +555,8 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption :param max_answer_chars: max result length; -1 for default :return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols. """ + self.project.ls_sync_file_system_changes() + symbol_retriever = self.create_language_server_symbol_retriever() diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( name_path=name_path, @@ -693,6 +703,8 @@ class SafeDeleteSymbol(Tool, ToolMarkerSymbolicEdit): :param name_path_pattern: name path of the symbol to delete :param relative_path: the relative path to the file containing the symbol to delete """ + self.project.ls_sync_file_system_changes() + ls_symbol_retriever = self.create_language_server_symbol_retriever() symbol = ls_symbol_retriever.find_unique(name_path_pattern, substring_matching=False, within_relative_path=relative_path) symbol_rel_path = symbol.relative_path diff --git a/test/serena/test_ls_file_sync.py b/test/serena/test_ls_file_sync.py new file mode 100644 index 00000000..babe4bab --- /dev/null +++ b/test/serena/test_ls_file_sync.py @@ -0,0 +1,92 @@ +""" +End-to-end test for language server file synchronisation as a result of externally-made file changes. + +Exercises all three ``FileChangeType`` branches against a real pyright backend: +Created (new caller file), Changed (append a second caller), Deleted (remove the caller file). +""" + +import os +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import get_repo_path, project_with_ls_context + +pytestmark = pytest.mark.python + +# A method that already has a call site in the fixture, so its baseline reference set is non-empty. +_TARGET_FILE = os.path.join("test_repo", "services.py") +_TARGET_SYMBOL = "create_user" + +_CALLER_REL_PATH = os.path.join("test_repo", "external_caller.py") +_CALLER_ONE = "external_caller_one" +_CALLER_TWO = "external_caller_two" + + +def _caller_source(*function_names: str) -> str: + body = "\n\n".join( + f"def {name}() -> User:\n return UserService().{_TARGET_SYMBOL}('x', 'y', 'z@example.com')" for name in function_names + ) + return "from test_repo.models import User\nfrom test_repo.services import UserService\n\n\n" + body + "\n" + + +def _referencing_symbol_names(ls: SolidLanguageServer) -> list[str]: + """Names of the symbols that reference ``_TARGET_SYMBOL``, per the warm language server.""" + document_symbols = ls.request_document_symbols(_TARGET_FILE).get_all_symbols_and_roots() + target = next((s for s in document_symbols[0] if s.get("name") == _TARGET_SYMBOL), None) + assert target is not None and "selectionRange" in target, f"{_TARGET_SYMBOL} not found in {_TARGET_FILE}" + start = target["selectionRange"]["start"] + references = ls.request_referencing_symbols(_TARGET_FILE, start["line"], start["character"]) + return [ref.symbol["name"] for ref in references] + + +def test_external_changes_become_visible_after_sync(tmp_path): + # Work on an isolated copy so we can freely create/edit/delete files under the project root. + repo_root = tmp_path / "repo" + shutil.copytree(get_repo_path(Language.PYTHON), repo_root) + caller_abs = repo_root / _CALLER_REL_PATH + + with project_with_ls_context(Language.PYTHON, str(repo_root)) as project: + language_server = next(iter(project.language_server_manager.iter_language_servers())) + + def sync_fs() -> int: + return project.language_server_manager.sync_file_system_changes() + + # Warm the reference index, then establish the freshness baseline (first poll never notifies). + _referencing_symbol_names(language_server) + assert sync_fs() == 0, "first poll must only establish the baseline" + + # --- Created ------------------------------------------------------------------------------- + caller_abs.write_text(_caller_source(_CALLER_ONE), encoding="utf-8") + + # Adversarial: without the poll, the warm server has never seen the new file -> stale. + assert _CALLER_ONE not in _referencing_symbol_names(language_server), "expected the new caller to be invisible before poll" + + assert sync_fs() == 1, "one Created event expected" + assert _CALLER_ONE in _referencing_symbol_names(language_server), "new caller must be visible after poll" + + # --- Changed ------------------------------------------------------------------------------- + caller_abs.write_text(_caller_source(_CALLER_ONE, _CALLER_TWO), encoding="utf-8") + assert sync_fs() == 1, "one Changed event expected" + names_after_change = _referencing_symbol_names(language_server) + assert _CALLER_TWO in names_after_change, "appended caller must be visible after poll" + assert _CALLER_ONE in names_after_change + + # --- Deleted ------------------------------------------------------------------------------- + caller_abs.unlink() + assert sync_fs() == 1, "one Deleted event expected" + names_after_delete = _referencing_symbol_names(language_server) + assert _CALLER_ONE not in names_after_delete, "deleted caller must disappear after poll" + assert _CALLER_TWO not in names_after_delete + + +def test_first_poll_is_baseline_only(tmp_path): + """A newly-loaded project must not report its entire pre-existing file set as changed.""" + repo_root = tmp_path / "repo" + shutil.copytree(get_repo_path(Language.PYTHON), repo_root) + with project_with_ls_context(Language.PYTHON, str(repo_root)) as project: + assert project.ls_sync_file_system_changes() == 0 + # No external edits between the two calls -> still nothing to report. + assert project.ls_sync_file_system_changes() == 0 From 75ab3e5cc8b4ad95c66456ac4d0fb227e1995933 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Mon, 20 Jul 2026 19:16:42 +0200 Subject: [PATCH 162/170] Bump `anthropic` from 0.59.0 to 0.117.0 0.59.0 caused a compatibility warning to be printed when run with Python 3.14 Resolves #1725 --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- uv.lock | 13 +++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7a0a0f..83a4343d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,8 @@ Status of the `main` branch. Changes prior to the next official version change w `displayLastExecution(null)` path, mirroring the other panels #1713 * Dependencies: - - Bump mcp from 1.27.0 to 1.28.1 + - Bump `mcp` from 1.27.0 to 1.28.1 + - Bump `anthropic` from 0.59.0 to 0.117.0 # v1.6.0 (2026-07-16) diff --git a/pyproject.toml b/pyproject.toml index 4433faaa..5dfe081a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "joblib==1.5.1", "tqdm==4.67.1", "tiktoken==0.12.0", - "anthropic==0.59.0", + "anthropic==0.117.0", "beautifulsoup4==4.14.2", "pywebview==6.2", "pystray==0.19.5", diff --git a/uv.lock b/uv.lock index 86e307b1..eb03c9e5 100644 --- a/uv.lock +++ b/uv.lock @@ -75,20 +75,21 @@ wheels = [ [[package]] name = "anthropic" -version = "0.59.0" +version = "0.117.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, + { name = "docstring-parser" }, { name = "httpx" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/52daff015f5a1f24eec891b3041f5f816712fea8b5113dc76638bcbc23d8/anthropic-0.59.0.tar.gz", hash = "sha256:d710d1ef0547ebbb64b03f219e44ba078e83fc83752b96a9b22e9726b523fd8f", size = 425679, upload-time = "2025-07-23T16:23:16.901Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/0d/8f71d535edb0d438f023bd825fb65f67c14fa88a2bd6b75f292a58a63de4/anthropic-0.117.0.tar.gz", hash = "sha256:98107f2b76439641e0ae2a1754087534b8f178dbab99d6eb1bc4b7bc8c744496", size = 989933, upload-time = "2026-07-16T19:36:13.07Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/b1/03f680393eac04afd8f2be44ee0e39e033c40faf43dbc1c11764b07a2687/anthropic-0.59.0-py3-none-any.whl", hash = "sha256:cbc8b3dccef66ad6435c4fa1d317e5ebb092399a4b88b33a09dc4bf3944c3183", size = 293057, upload-time = "2025-07-23T16:23:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4c/917d21d6619a4475cdafc6d13a69fdb3b901ddac57e76caca5a25c117b6d/anthropic-0.117.0-py3-none-any.whl", hash = "sha256:451a0a6905f11dff7663d13e4ee5dbf909eb8942b1d049803c7b937a13ac47ec", size = 998327, upload-time = "2026-07-16T19:36:11.225Z" }, ] [[package]] @@ -427,7 +428,7 @@ name = "clr-loader" version = "0.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi" }, + { name = "cffi", marker = "python_full_version < '3.13' or sys_platform == 'win32'" }, ] 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 = [ @@ -2302,7 +2303,7 @@ name = "pythonnet" version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader" }, + { name = "clr-loader", marker = "python_full_version < '3.13' or sys_platform == 'win32'" }, ] 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 = [ @@ -2882,7 +2883,7 @@ google = [ [package.metadata] requires-dist = [ { name = "agno", marker = "extra == 'agno'", specifier = "==2.6.6" }, - { name = "anthropic", specifier = "==0.59.0" }, + { name = "anthropic", specifier = "==0.117.0" }, { name = "beautifulsoup4", specifier = "==4.14.2" }, { name = "cryptography", specifier = "==48.0.1" }, { name = "docstring-parser", specifier = "==0.17.0" }, From 30073d36d8df58a4df8b5362c21e0a258295b618 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:24:32 +0700 Subject: [PATCH 163/170] Retry LSP ContentModified (-32801) errors instead of failing hard Per the LSP spec, ContentModified means the server discarded a stale, in-flight computation because the workspace changed underneath it, not that the request itself is invalid, so a well-behaved client is expected to retry. LanguageServerInterface.send_request previously converted any such response straight into a hard SolidLSPException, with no retry, for every language server. This was flaking test_find_symbol[rust_add_function] on windows-latest: rust-analyzer returns ContentModified for hover requests cancelled by concurrent indexing (its internal retry set does not cover hover), and nothing on the client side retried it. send_request now retries ContentModified responses a bounded number of times with a short delay before giving up. This is generic to the base request path, so it applies to every language server, not just rust-analyzer. RequestCancelled (-32800) is left alone since the LSP spec defines it as client-initiated. Fixes #1724 --- CHANGELOG.md | 7 ++ src/solidlsp/ls_process.py | 52 ++++++++---- test/solidlsp/test_content_modified_retry.py | 85 ++++++++++++++++++++ 3 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 test/solidlsp/test_content_modified_retry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a46a53..63f411ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,13 @@ Status of the `main` branch. Changes prior to the next official version change w - PHP/Intelephense: expose `file_filter` via `ls_specific_settings["php"]`, so that additional extensions containing PHP sources (e.g. Drupal's `.module` / `.install` / `.inc` / `.theme`) become visible to the symbol tools and are indexed by the language server #1710 + - Fix: an LSP `ContentModified` (-32801) response was surfaced as a hard `SolidLSPException` + instead of being retried. Per the LSP spec, `ContentModified` means the server discarded a + stale, in-flight computation because the workspace changed underneath it, not that the request + itself is invalid; well-behaved clients are expected to retry. `LanguageServerInterface.send_request` + now retries such requests a bounded number of times before giving up, for all language servers + (this was flaking `test_find_symbol[rust_add_function]` on windows-latest, where rust-analyzer + returns `ContentModified` for hover requests cancelled by concurrent indexing). #1724 * JetBrains: - Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index 9fc84a8e..a7b97cde 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -18,7 +18,7 @@ from solidlsp.ls_config import Language from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_request import LanguageServerRequest from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification -from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes +from solidlsp.lsp_protocol_handler.lsp_types import ErrorCodes, LSPErrorCodes from solidlsp.lsp_protocol_handler.server import ( ENCODING, LSPError, @@ -36,6 +36,13 @@ from solidlsp.util import subprocess_util log = logging.getLogger(__name__) +# Per the LSP spec, `ContentModified` (-32801) means the server discarded a stale, in-flight +# computation because the workspace changed underneath it, not that the request itself is +# invalid. A well-behaved client is expected to retry such requests rather than treat them as +# a hard failure, so we do so here, generically, for all requests to any language server. +_CONTENT_MODIFIED_MAX_ATTEMPTS = 5 +_CONTENT_MODIFIED_RETRY_DELAY = 0.2 + class LanguageServerTerminatedException(Exception): """ @@ -298,29 +305,42 @@ class LanguageServerInterface(ABC): def send_request(self, method: str, params: dict | None = None) -> PayloadLike: """ - Send request to the server, register the request id, and wait for the response + Send request to the server, register the request id, and wait for the response. + + Requests that fail with the LSP `ContentModified` error (-32801) are retried a bounded + number of times with a short delay instead of being surfaced to the caller immediately; + see `_CONTENT_MODIFIED_MAX_ATTEMPTS` above. """ - with self._request_id_lock: - request_id = self.request_id - self.request_id += 1 + for attempt in range(1, _CONTENT_MODIFIED_MAX_ATTEMPTS + 1): + with self._request_id_lock: + request_id = self.request_id + self.request_id += 1 - request = Request(request_id=request_id, method=method) - log.debug("Starting: %s", request) + request = Request(request_id=request_id, method=method) + log.debug("Starting: %s", request) - with self._response_handlers_lock: - self._pending_requests[request_id] = request + with self._response_handlers_lock: + self._pending_requests[request_id] = request - self._send_payload(make_request(method, request_id, params)) + self._send_payload(make_request(method, request_id, params)) - log.debug("Waiting for response to request %s with params:\n%s", method, params) - result = request.get_result(timeout=self._request_timeout) - log.debug("Completed: %s", request) + log.debug("Waiting for response to request %s with params:\n%s", method, params) + result = request.get_result(timeout=self._request_timeout) + log.debug("Completed: %s", request) + + if not result.is_error(): + log.debug("Returning result:\n%s", result.payload) + return result.payload + + is_content_modified = isinstance(result.error, LSPError) and result.error.code == LSPErrorCodes.ContentModified + if attempt < _CONTENT_MODIFIED_MAX_ATTEMPTS and is_content_modified: + log.info("Request %s got ContentModified (-32801); retrying (%d/%d)", method, attempt, _CONTENT_MODIFIED_MAX_ATTEMPTS) + time.sleep(_CONTENT_MODIFIED_RETRY_DELAY) + continue - if result.is_error(): raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error - log.debug("Returning result:\n%s", result.payload) - return result.payload + raise AssertionError("unreachable") # loop always returns or raises @abstractmethod def _send_payload(self, payload: StringDict) -> None: diff --git a/test/solidlsp/test_content_modified_retry.py b/test/solidlsp/test_content_modified_retry.py new file mode 100644 index 00000000..27e05d10 --- /dev/null +++ b/test/solidlsp/test_content_modified_retry.py @@ -0,0 +1,85 @@ +"""Unit tests: ``LanguageServerInterface.send_request`` retries LSP ``ContentModified`` errors. + +Per the LSP spec, ``ContentModified`` (-32801) means the server discarded a stale, in-flight +computation because the workspace changed underneath it, not that the request itself is +invalid -- clients are expected to retry. This is what caused issue #1724 (flaky +``test_find_symbol[rust_add_function]`` on windows-latest): rust-analyzer returns +``ContentModified`` for cancelled hover requests, and Serena surfaced it as a hard error. + +No language markers: these use a local test double and run in catch-all. +""" + +from __future__ import annotations + +import logging + +import pytest + +from solidlsp import ls_process +from solidlsp.ls_config import Language +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_process import LanguageServerInterface, Request +from solidlsp.lsp_protocol_handler.lsp_types import LSPErrorCodes +from solidlsp.lsp_protocol_handler.server import LSPError + + +class _ScriptedServer(LanguageServerInterface): + """Test double that answers each request synchronously from a scripted list of results, + without a real language server process. + """ + + def __init__(self, results: list[Request.Result]) -> None: + super().__init__(Language.PYTHON, lambda _line: logging.INFO) + self._results = list(results) + self.sent_payload_count = 0 + + def is_running(self) -> bool: + return True + + def _start(self) -> None: + pass + + def _stop(self, timeout: float) -> None: + pass + + def _send_payload(self, payload: dict) -> None: + self.sent_payload_count += 1 + request = self._pending_requests[payload["id"]] + result = self._results.pop(0) + if result.is_error(): + request.on_error(result.error) + else: + request.on_result(result.payload) + + +def _content_modified() -> Request.Result: + return Request.Result(error=LSPError(LSPErrorCodes.ContentModified, "content modified")) + + +def test_content_modified_is_retried_until_success() -> None: + server = _ScriptedServer([_content_modified(), _content_modified(), Request.Result(payload={"contents": "ok"})]) + assert server.send_request("textDocument/hover") == {"contents": "ok"} + assert server.sent_payload_count == 3 + + +def test_content_modified_gives_up_after_max_attempts() -> None: + max_attempts = ls_process._CONTENT_MODIFIED_MAX_ATTEMPTS + server = _ScriptedServer([_content_modified() for _ in range(max_attempts)]) + with pytest.raises(SolidLSPException): + server.send_request("textDocument/hover") + assert server.sent_payload_count == max_attempts + + +def test_other_lsp_errors_are_not_retried() -> None: + server = _ScriptedServer([Request.Result(error=LSPError(LSPErrorCodes.RequestFailed, "boom"))]) + with pytest.raises(SolidLSPException): + server.send_request("textDocument/hover") + assert server.sent_payload_count == 1 + + +def test_request_cancelled_is_not_retried() -> None: + """-32800 is client-initiated cancellation per the LSP spec, so it must not be retried here.""" + server = _ScriptedServer([Request.Result(error=LSPError(LSPErrorCodes.RequestCancelled, "cancelled"))]) + with pytest.raises(SolidLSPException): + server.send_request("textDocument/hover") + assert server.sent_payload_count == 1 From 8fa67596467940bccf0f237ae51c0eeb27fceecd Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:08:20 +0700 Subject: [PATCH 164/170] Gate ContentModified retry on declared retryOnContentModified methods Review on #1728 found two blocking issues in the previous commit: 1. pyrefly_server.py wraps send_request with its own 5x/0.2s retry for RequestCancelled/ContentModified. The base-class retry nested inside it, so a persistent ContentModified produced 5x5=25 round-trips. 2. The base-class retry applied to every request on every server, but general.staleRequestSupport.retryOnContentModified in InitializeParams is the client's contract with the server about exactly which requests it will reissue. rust-analyzer declares only the three semanticTokens methods and haskell-language-server declares none; blanket-retrying e.g. textDocument/hover (or workspace/executeCommand, which is not idempotent) contradicts that declared capability. Fix: send_request now only retries a method if it was registered via the new LanguageServerInterface.set_content_modified_retry_methods, which SolidLanguageServer._create_initialize_params populates from each server's own general.staleRequestSupport.retryOnContentModified list right after building it. The mechanism is opt-in per method, per server, and mirrors what we actually told the server -- so it's a no-op wherever nothing is declared. rust-analyzer now declares textDocument/hover in that list (the method issue #1724's repro -- test_find_symbol[rust_add_function] with include_info=True -- actually needs), in addition to the pre-existing semanticTokens methods. This also resolves the pyrefly double-retry: pyrefly never declares general.staleRequestSupport, so the base-class retry is a no-op for every pyrefly request and pyrefly's own wrapper remains the sole handler for both RequestCancelled and ContentModified there, exactly as before this PR. pyrefly_server.py is intentionally left untouched; narrowing its retry tuple, as literally suggested, would have removed ContentModified handling for it entirely once the base layer stopped covering it too. Also: reduced _CONTENT_MODIFIED_MAX_ATTEMPTS from 5 to 3 (now the max per allowlisted method only, so worst-case added latency for hover is 2 * request_timeout + 0.4s instead of 4 * request_timeout + 0.8s), and restructured send_request around a _send_request_once helper so the final failed attempt raises directly instead of falling through to a trailing `raise AssertionError("unreachable")`. Retry tests now monkeypatch time.sleep and gained coverage for the allowlist gate (undeclared method, executeCommand, default-empty registration). Fixes #1724 --- CHANGELOG.md | 10 +- .../language_servers/rust_analyzer.py | 4 + src/solidlsp/ls.py | 15 ++- src/solidlsp/ls_process.py | 101 ++++++++++++------ test/solidlsp/test_content_modified_retry.py | 35 +++++- 5 files changed, 125 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f411ac..fbda3e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,12 +52,10 @@ Status of the `main` branch. Changes prior to the next official version change w extensions containing PHP sources (e.g. Drupal's `.module` / `.install` / `.inc` / `.theme`) become visible to the symbol tools and are indexed by the language server #1710 - Fix: an LSP `ContentModified` (-32801) response was surfaced as a hard `SolidLSPException` - instead of being retried. Per the LSP spec, `ContentModified` means the server discarded a - stale, in-flight computation because the workspace changed underneath it, not that the request - itself is invalid; well-behaved clients are expected to retry. `LanguageServerInterface.send_request` - now retries such requests a bounded number of times before giving up, for all language servers - (this was flaking `test_find_symbol[rust_add_function]` on windows-latest, where rust-analyzer - returns `ContentModified` for hover requests cancelled by concurrent indexing). #1724 + instead of being retried, as the spec expects for requests a client declared it will reissue. + `send_request` now retries such responses for methods declared via a server's + `retryOnContentModified` capability; rust-analyzer declares `textDocument/hover`, fixing the + flaky `test_find_symbol[rust_add_function]` on windows-latest. #1724 * JetBrains: - Allow external files from dependencies (specified via references like "<ext:FileUtil.class|472e0a13>") to be diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index 2c01ed87..d7cd97fc 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -413,6 +413,10 @@ class RustAnalyzer(SolidLanguageServer): "textDocument/semanticTokens/full", "textDocument/semanticTokens/range", "textDocument/semanticTokens/full/delta", + # rust-analyzer's internal cancellation tracking does not cover hover, + # so hover requests it cancels while indexing come back ContentModified; + # we retry them ourselves (see LanguageServerInterface.send_request). #1724 + "textDocument/hover", ], }, "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 820d241d..4bdb0f00 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -3203,4 +3203,17 @@ class SolidLanguageServer(ABC): """ Create the InitializeParams object to send to the language server during initialization. """ - return self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build() + params = self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build() + self._register_content_modified_retry_methods(params) + return params + + def _register_content_modified_retry_methods(self, params: InitializeParams) -> None: + """ + Reads back `general.staleRequestSupport.retryOnContentModified` from the InitializeParams + we are about to send and registers it with the underlying `LanguageServerInterface`, so + that `send_request` only retries `ContentModified` (-32801) responses for methods we have + actually told the server we will retry (see `LanguageServerInterface.send_request`). + """ + general_capabilities = cast(dict, params.get("capabilities", {})).get("general", {}) + retry_methods = general_capabilities.get("staleRequestSupport", {}).get("retryOnContentModified", []) + self.server.set_content_modified_retry_methods(retry_methods) diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index a7b97cde..b7f1a3d8 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -7,7 +7,7 @@ import subprocess import threading import time from abc import ABC, abstractmethod -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from queue import Empty, Queue from typing import IO, Any, AnyStr, cast @@ -38,9 +38,13 @@ log = logging.getLogger(__name__) # Per the LSP spec, `ContentModified` (-32801) means the server discarded a stale, in-flight # computation because the workspace changed underneath it, not that the request itself is -# invalid. A well-behaved client is expected to retry such requests rather than treat them as -# a hard failure, so we do so here, generically, for all requests to any language server. -_CONTENT_MODIFIED_MAX_ATTEMPTS = 5 +# invalid. A well-behaved client is expected to retry such requests -- but only requests it has +# declared, via `general.staleRequestSupport.retryOnContentModified` in its InitializeParams, that +# it will retry. We honor that contract: `send_request` only retries methods a given language +# server implementation has actually opted into via `set_content_modified_retry_methods` (see +# `SolidLanguageServer._create_initialize_params`), so this mechanism is a no-op for any server +# that hasn't declared such methods. +_CONTENT_MODIFIED_MAX_ATTEMPTS = 3 _CONTENT_MODIFIED_RETRY_DELAY = 0.2 @@ -162,12 +166,34 @@ class LanguageServerInterface(ABC): self._response_handlers_lock = threading.Lock() self._tasks_lock = threading.Lock() + self._content_modified_retry_methods: frozenset[str] = frozenset() + """ + The set of LSP method names for which `send_request` will retry a `ContentModified` + (-32801) response instead of raising immediately. Empty by default, i.e. no retries, + since retrying is only correct for methods this client has told the server (via + `general.staleRequestSupport.retryOnContentModified`) that it will reissue; see + `set_content_modified_retry_methods`. + """ + def set_request_timeout(self, timeout: float | None) -> None: """ :param timeout: the timeout, in seconds, for all requests sent to the language server. """ self._request_timeout = timeout + def set_content_modified_retry_methods(self, methods: Iterable[str]) -> None: + """ + Declares the LSP method names for which `send_request` should retry `ContentModified` + (-32801) responses. This should mirror whatever methods are declared in + `general.staleRequestSupport.retryOnContentModified` of the InitializeParams sent to the + server: that declaration is a promise to the server about which requests the client will + reissue on cancellation, so retrying anything outside of it would contradict what the + server was told. + + :param methods: LSP method names (e.g. ``"textDocument/hover"``) eligible for retry. + """ + self._content_modified_retry_methods = frozenset(methods) + @abstractmethod def is_running(self) -> bool: """ @@ -303,44 +329,55 @@ class LanguageServerInterface(ABC): request.on_error(exception) self._pending_requests.clear() + def _send_request_once(self, method: str, params: dict | None) -> Request.Result: + """ + Sends a single request to the server (no retries) and waits for its response. + """ + with self._request_id_lock: + request_id = self.request_id + self.request_id += 1 + + request = Request(request_id=request_id, method=method) + log.debug("Starting: %s", request) + + with self._response_handlers_lock: + self._pending_requests[request_id] = request + + self._send_payload(make_request(method, request_id, params)) + + log.debug("Waiting for response to request %s with params:\n%s", method, params) + result = request.get_result(timeout=self._request_timeout) + log.debug("Completed: %s", request) + return result + def send_request(self, method: str, params: dict | None = None) -> PayloadLike: """ Send request to the server, register the request id, and wait for the response. - Requests that fail with the LSP `ContentModified` error (-32801) are retried a bounded + If `method` is one of the methods registered via `set_content_modified_retry_methods`, + a response failing with the LSP `ContentModified` error (-32801) is retried a bounded number of times with a short delay instead of being surfaced to the caller immediately; - see `_CONTENT_MODIFIED_MAX_ATTEMPTS` above. + see `_CONTENT_MODIFIED_MAX_ATTEMPTS` above. Methods that were not registered are never + retried, regardless of the error, so as to not retry requests behind the server's back. """ - for attempt in range(1, _CONTENT_MODIFIED_MAX_ATTEMPTS + 1): - with self._request_id_lock: - request_id = self.request_id - self.request_id += 1 + result = self._send_request_once(method, params) + if not result.is_error(): + log.debug("Returning result:\n%s", result.payload) + return result.payload - request = Request(request_id=request_id, method=method) - log.debug("Starting: %s", request) - - with self._response_handlers_lock: - self._pending_requests[request_id] = request - - self._send_payload(make_request(method, request_id, params)) - - log.debug("Waiting for response to request %s with params:\n%s", method, params) - result = request.get_result(timeout=self._request_timeout) - log.debug("Completed: %s", request) - - if not result.is_error(): - log.debug("Returning result:\n%s", result.payload) - return result.payload - - is_content_modified = isinstance(result.error, LSPError) and result.error.code == LSPErrorCodes.ContentModified - if attempt < _CONTENT_MODIFIED_MAX_ATTEMPTS and is_content_modified: + if method in self._content_modified_retry_methods: + for attempt in range(2, _CONTENT_MODIFIED_MAX_ATTEMPTS + 1): + is_content_modified = isinstance(result.error, LSPError) and result.error.code == LSPErrorCodes.ContentModified + if not is_content_modified: + break log.info("Request %s got ContentModified (-32801); retrying (%d/%d)", method, attempt, _CONTENT_MODIFIED_MAX_ATTEMPTS) time.sleep(_CONTENT_MODIFIED_RETRY_DELAY) - continue + result = self._send_request_once(method, params) + if not result.is_error(): + log.debug("Returning result:\n%s", result.payload) + return result.payload - raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error - - raise AssertionError("unreachable") # loop always returns or raises + raise SolidLSPException(f"Error processing request {method} with params:\n{params}", cause=result.error) from result.error @abstractmethod def _send_payload(self, payload: StringDict) -> None: diff --git a/test/solidlsp/test_content_modified_retry.py b/test/solidlsp/test_content_modified_retry.py index 27e05d10..b36669bb 100644 --- a/test/solidlsp/test_content_modified_retry.py +++ b/test/solidlsp/test_content_modified_retry.py @@ -6,6 +6,11 @@ invalid -- clients are expected to retry. This is what caused issue #1724 (flaky ``test_find_symbol[rust_add_function]`` on windows-latest): rust-analyzer returns ``ContentModified`` for cancelled hover requests, and Serena surfaced it as a hard error. +Retrying is only spec-compliant for methods the client actually declared, via +``general.staleRequestSupport.retryOnContentModified`` in its InitializeParams, that it will +retry -- so ``send_request`` only retries methods registered with it through +``set_content_modified_retry_methods`` (see ``SolidLanguageServer._create_initialize_params``). + No language markers: these use a local test double and run in catch-all. """ @@ -23,15 +28,22 @@ from solidlsp.lsp_protocol_handler.lsp_types import LSPErrorCodes from solidlsp.lsp_protocol_handler.server import LSPError +@pytest.fixture(autouse=True) +def _no_real_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + """The retry delay is only there to be polite to the real server; don't pay for it in tests.""" + monkeypatch.setattr(ls_process.time, "sleep", lambda _seconds: None) + + class _ScriptedServer(LanguageServerInterface): """Test double that answers each request synchronously from a scripted list of results, without a real language server process. """ - def __init__(self, results: list[Request.Result]) -> None: + def __init__(self, results: list[Request.Result], retry_methods: tuple[str, ...] = ("textDocument/hover",)) -> None: super().__init__(Language.PYTHON, lambda _line: logging.INFO) self._results = list(results) self.sent_payload_count = 0 + self.set_content_modified_retry_methods(retry_methods) def is_running(self) -> bool: return True @@ -83,3 +95,24 @@ def test_request_cancelled_is_not_retried() -> None: with pytest.raises(SolidLSPException): server.send_request("textDocument/hover") assert server.sent_payload_count == 1 + + +def test_content_modified_is_not_retried_for_undeclared_method() -> None: + """Retrying is a promise to the server about which methods will be reissued (per + ``general.staleRequestSupport.retryOnContentModified``); a method that was never registered + via ``set_content_modified_retry_methods`` must not be retried, even on ContentModified. + This also covers non-idempotent requests like ``workspace/executeCommand``, which no server + declaration should ever include. + """ + server = _ScriptedServer([_content_modified()], retry_methods=("textDocument/hover",)) + with pytest.raises(SolidLSPException): + server.send_request("workspace/executeCommand") + assert server.sent_payload_count == 1 + + +def test_content_modified_retry_methods_default_to_empty() -> None: + """A server that never calls `set_content_modified_retry_methods` must not retry anything.""" + server = _ScriptedServer([_content_modified()], retry_methods=()) + with pytest.raises(SolidLSPException): + server.send_request("textDocument/hover") + assert server.sent_payload_count == 1 From d6993fe8b6c851aa728fca9ede2cea6137b55b1a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 21 Jul 2026 12:04:43 +0200 Subject: [PATCH 165/170] Fix docstring change being applied to the wrong 'find references' tool --- src/serena/tools/jetbrains_tools.py | 3 ++- src/serena/tools/symbol_tools.py | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index a46736ba..543811e9 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -259,7 +259,6 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark symbol_dict_grouper = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True) - # TODO: (maybe) - add content snippets showing the references like in LS based version? def apply( self, name_path: str, @@ -273,6 +272,8 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark :param name_path: name path of the symbol for which to find references :param relative_path: the relative path to the file containing the symbol (must be a file, not a directory) + Note: for external dependencies, this must be an identifier starting with `<ext` that you have received + earlier (don't try to guess!). :param max_answer_chars: max characters for the result (-1 for default). If exceeded, no content/a shortened result is returned. """ relative_path = self._sanitize_input_param(relative_path) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index f79333e4..308ab0cb 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -267,8 +267,6 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): :param name_path: name path of the symbol :param relative_path: the relative path to the file containing the symbol for which to find references. - Note: for external dependencies, this must be an identifier starting with `<ext` that you have received - earlier (don't try to guess!). :param include_kinds: (optional) limits results to the given LSP symbol kinds (integers) :param exclude_kinds: optional list of LSP symbol kinds (integers) to exclude. :param max_answer_chars: max result length; -1 for default From 101d7a01d97df8057ea00ef5f629ffdaffe0b98f Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 21 Jul 2026 12:33:23 +0200 Subject: [PATCH 166/170] SymbolDictGrouper: Allow grouper to be temporarily disabled via disabled_context --- src/serena/symbol.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 8eb00595..6a17813a 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -5,6 +5,7 @@ import os from abc import ABC, abstractmethod from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Sequence +from contextlib import contextmanager from dataclasses import asdict, dataclass from time import perf_counter from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar @@ -1261,6 +1262,7 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): self._group_keys = group_keys self._group_children_keys = group_children_keys self._collapse_singleton = collapse_singleton + self._is_enabled = True def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str], is_children: bool) -> dict[str, Any] | list[Any]: """ @@ -1321,8 +1323,24 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): """ # avoid side effects by working on a deep-copy symbols_copy = copy.deepcopy(symbols) + + if not self._is_enabled: + return symbols_copy + return self._group_by(symbols_copy, self._group_keys, self._group_children_keys, is_children=False) + @contextmanager + def disabled_context(self) -> Iterator[None]: + """ + A context manager that temporarily disables grouping. + """ + original_state = self._is_enabled + self._is_enabled = False + try: + yield + finally: + self._is_enabled = original_state + class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.OutputDict]): def __init__( From a9c72f93e4238bec8266eef5c05964b6b620b450 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 21 Jul 2026 11:00:10 +0200 Subject: [PATCH 167/170] Fix: Order of ignore patterns passed to language servers was not respected Resolves #1729 --- CHANGELOG.md | 1 + src/solidlsp/ls.py | 13 ++----------- src/solidlsp/ls_config.py | 5 ++++- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a46a53..2bbebfcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Status of the `main` branch. Changes prior to the next official version change w symbolic operations (such as `find_referencing_symbols`) to report stale information. An explicit file system polling mechanism is now used to detect changes prior to the affected tool executions. + - Fix: Order of ignore patterns passed to language servers was not respected #1729 - Improve uv-based language server launch command compatibility: Use the more widely supported `uv tool run` instead of `uv x` #1721 - Java (JDT-LS): add `runtimes` to `ls_specific_settings.java`, a list of extra JRE/JDK entries diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 820d241d..6fd94c5e 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -547,17 +547,8 @@ class SolidLanguageServer(ABC): """ self.server.on_any_notification(self._observe_server_notification) - # Set up the pathspec matcher for the ignored paths - # for all absolute paths in ignored_paths, convert them to relative paths - processed_patterns = [] - for pattern in set(config.ignored_paths): - # Normalize separators (pathspec expects forward slashes) - pattern = pattern.replace(os.path.sep, "/") - processed_patterns.append(pattern) - log.debug(f"Processing {len(processed_patterns)} ignored paths from the config") - - # Create a pathspec matcher from the processed patterns - self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) + # create a pathspec matcher from the given patterns + self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, config.ignored_paths) self._has_waited_for_cross_file_references = False diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index f5b6edea..2ba90791 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -910,7 +910,10 @@ class LanguageServerConfig: trace_lsp_communication: bool = False start_independent_lsp_process: bool = True ignored_paths: list[str] = field(default_factory=list) - """Paths, dirs or glob-like patterns. The matching will follow the same logic as for .gitignore entries""" + """ + list of ordered ignore patterns (same syntax as .gitignore; only forward slashes) to be used by the language server + for filtering out files and folders from indexing and analysis. + """ encoding: str = "utf-8" """File encoding to use when reading source files""" From ba7cdffc025fc030ba7582e8fb1ef790e412005a Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 21 Jul 2026 12:33:41 +0200 Subject: [PATCH 168/170] FindReferencingSymbolsTool: Limit file system sync to the necessary case (i.e. no full symbol search), adding tests that specifically test the tool path --- src/serena/tools/symbol_tools.py | 4 +- test/conftest.py | 17 ++- test/serena/test_ls_file_sync.py | 172 ++++++++++++++++++++----------- 3 files changed, 131 insertions(+), 62 deletions(-) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 308ab0cb..69d24d81 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -272,7 +272,9 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): :param max_answer_chars: max result length; -1 for default :return: a list of JSON objects with the symbols referencing the requested symbol """ - self.project.ls_sync_file_system_changes() + # file system sync needed for case where symbol finder does not perform a global search, updating everything + if relative_path: + self.project.ls_sync_file_system_changes() include_body = False # It is probably never a good idea to include the body of the referencing symbols parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None diff --git a/test/conftest.py b/test/conftest.py index 6b25dec3..626cdb78 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -13,6 +13,7 @@ import pytest from _pytest.mark import Mark, MarkDecorator from sensai.util.logging import configure +from serena.agent import SerenaAgent from serena.config.serena_config import SerenaConfig, SerenaPaths from serena.constants import SERENA_MANAGED_DIR_NAME from serena.project import Project @@ -129,7 +130,7 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer def create_default_serena_config(): - return SerenaConfig(gui_log_window=False, web_dashboard=False) + return SerenaConfig().with_headless_mode_overrides() def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project: @@ -238,6 +239,20 @@ def project_with_ls_context(language: Language, repo_root_override: str | None = yield project +@contextmanager +def agent_for_project_context(language: Language, repo_root_override: str | None = None) -> Iterator[SerenaAgent]: + project_root = str(get_repo_path(language)) if repo_root_override is None else repo_root_override + agent = SerenaAgent(project=project_root, serena_config=create_default_serena_config()) + + # wait for agent to be ready + agent.execute_task(lambda: None) + + try: + yield agent + finally: + agent.on_shutdown() + + @pytest.fixture(scope="module") def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]: if not hasattr(request, "param"): diff --git a/test/serena/test_ls_file_sync.py b/test/serena/test_ls_file_sync.py index babe4bab..feeacc8c 100644 --- a/test/serena/test_ls_file_sync.py +++ b/test/serena/test_ls_file_sync.py @@ -5,84 +5,136 @@ Exercises all three ``FileChangeType`` branches against a real pyright backend: Created (new caller file), Changed (append a second caller), Deleted (remove the caller file). """ +import json import os import shutil import pytest -from solidlsp import SolidLanguageServer +from serena.agent import SerenaAgent +from serena.project import Project +from serena.tools import FindReferencingSymbolsTool from solidlsp.ls_config import Language -from test.conftest import get_repo_path, project_with_ls_context +from test.conftest import agent_for_project_context, get_repo_path pytestmark = pytest.mark.python -# A method that already has a call site in the fixture, so its baseline reference set is non-empty. -_TARGET_FILE = os.path.join("test_repo", "services.py") -_TARGET_SYMBOL = "create_user" -_CALLER_REL_PATH = os.path.join("test_repo", "external_caller.py") -_CALLER_ONE = "external_caller_one" -_CALLER_TWO = "external_caller_two" +class FileSystemSyncTestCase: + """ + Tests the language server file system synchronisation behaviour, using a reference search + as an example of a feature that generally depends on the synchronisation. + + It supports two modes: + * using Serena's tools (where an explicit sync is not needed because it builds on find_symbol) + * a low-level mode that uses LS methods directly and therefore needs the sync + """ + + # A method that already has a call site in the fixture, so its baseline reference set is non-empty. + _TARGET_FILE = os.path.join("test_repo", "services.py") + _TARGET_SYMBOL = "create_user" + + _CALLER_REL_PATH = os.path.join("test_repo", "external_caller.py") + _CALLER_ONE = "external_caller_one" + _CALLER_TWO = "external_caller_two" + + def __init__(self, use_serena_tool: bool, tool_use_relative_path: bool = True): + """ + :param use_serena_tool: whether to use the Serena tool, which does not need an explicit FS sync + """ + self._use_serena_tool = use_serena_tool + self._tool_use_relative_path = tool_use_relative_path + + def _caller_source(self, *function_names: str) -> str: + body = "\n\n".join( + f"def {name}() -> User:\n return UserService().{self._TARGET_SYMBOL}('x', 'y', 'z@example.com')" for name in function_names + ) + return "from test_repo.models import User\nfrom test_repo.services import UserService\n\n\n" + body + "\n" + + def _referencing_symbol_names(self, agent: SerenaAgent) -> list[str]: + """Names of the symbols that reference ``_TARGET_SYMBOL``, per the warm language server.""" + if self._use_serena_tool: + tool = agent.get_tool(FindReferencingSymbolsTool) + with tool.symbol_dict_grouper.disabled_context(): + relative_path = self._TARGET_FILE if self._tool_use_relative_path else "" + response = tool.apply(name_path=self._TARGET_SYMBOL, relative_path=relative_path) + ref_symbols = json.loads(response) + symbol_names = [ref["name_path"].split("/")[-1] for ref in ref_symbols] + return symbol_names + else: + ls = next(iter(agent.get_active_project_or_raise().language_server_manager.iter_language_servers())) + document_symbols = ls.request_document_symbols(self._TARGET_FILE).get_all_symbols_and_roots() + target = next((s for s in document_symbols[0] if s.get("name") == self._TARGET_SYMBOL), None) + assert target is not None and "selectionRange" in target, f"{self._TARGET_SYMBOL} not found in {self._TARGET_FILE}" + start = target["selectionRange"]["start"] + references = ls.request_referencing_symbols(self._TARGET_FILE, start["line"], start["character"]) + return [ref.symbol["name"] for ref in references] + + def run(self, tmp_path): + # Work on an isolated copy so we can freely create/edit/delete files under the project root. + repo_root = tmp_path / "repo" + shutil.copytree(get_repo_path(Language.PYTHON), repo_root) + caller_abs = repo_root / self._CALLER_REL_PATH + + with agent_for_project_context(Language.PYTHON, str(repo_root)) as agent: + project = agent.get_active_project_or_raise() + + # Warm the reference index, then establish the freshness baseline (first poll never notifies). + self._referencing_symbol_names(agent) + self._sync_fs(project, 0, "<baseline>") + + # --- Created ------------------------------------------------------------------------------- + caller_abs.write_text(self._caller_source(self._CALLER_ONE), encoding="utf-8") + + if not self._use_serena_tool: + # Adversarial: without the poll, the warm server has never seen the new file -> stale. + assert self._CALLER_ONE not in self._referencing_symbol_names(agent), "expected the new caller to be invisible before poll" + + self._sync_fs(project, 1, "Created") + assert self._CALLER_ONE in self._referencing_symbol_names(agent), "new caller must be visible after poll" + + # --- Changed ------------------------------------------------------------------------------- + caller_abs.write_text(self._caller_source(self._CALLER_ONE, self._CALLER_TWO), encoding="utf-8") + self._sync_fs(project, 1, "Changed") + names_after_change = self._referencing_symbol_names(agent) + assert self._CALLER_TWO in names_after_change, "appended caller must be visible after poll" + assert self._CALLER_ONE in names_after_change + + # --- Deleted ------------------------------------------------------------------------------- + caller_abs.unlink() + self._sync_fs(project, 1, "Deleted") + names_after_delete = self._referencing_symbol_names(agent) + assert self._CALLER_ONE not in names_after_delete, "deleted caller must disappear after poll" + assert self._CALLER_TWO not in names_after_delete + + def _sync_fs(self, project: Project, num_expected_events: int, event_type: str): + # apply sync only if Serena's tool is not being used + if not self._use_serena_tool: + assert project.ls_sync_file_system_changes() == num_expected_events, f"expected {num_expected_events} {event_type} event(s)" -def _caller_source(*function_names: str) -> str: - body = "\n\n".join( - f"def {name}() -> User:\n return UserService().{_TARGET_SYMBOL}('x', 'y', 'z@example.com')" for name in function_names - ) - return "from test_repo.models import User\nfrom test_repo.services import UserService\n\n\n" + body + "\n" +def test_find_referencing_symbol_tool_handles_sync_with_relative_path(tmp_path): + """ + Tests that the FindReferencingSymbolsTool handles file system synchronisation correctly when passed a relative path. + """ + FileSystemSyncTestCase(use_serena_tool=True, tool_use_relative_path=True).run(tmp_path) -def _referencing_symbol_names(ls: SolidLanguageServer) -> list[str]: - """Names of the symbols that reference ``_TARGET_SYMBOL``, per the warm language server.""" - document_symbols = ls.request_document_symbols(_TARGET_FILE).get_all_symbols_and_roots() - target = next((s for s in document_symbols[0] if s.get("name") == _TARGET_SYMBOL), None) - assert target is not None and "selectionRange" in target, f"{_TARGET_SYMBOL} not found in {_TARGET_FILE}" - start = target["selectionRange"]["start"] - references = ls.request_referencing_symbols(_TARGET_FILE, start["line"], start["character"]) - return [ref.symbol["name"] for ref in references] +def test_find_referencing_symbol_tool_handles_sync_without_relative_path(tmp_path): + """ + Tests that the FindReferencingSymbolsTool handles file system synchronisation correctly when no relative path is provided. + """ + FileSystemSyncTestCase(use_serena_tool=True, tool_use_relative_path=False).run(tmp_path) -def test_external_changes_become_visible_after_sync(tmp_path): - # Work on an isolated copy so we can freely create/edit/delete files under the project root. - repo_root = tmp_path / "repo" - shutil.copytree(get_repo_path(Language.PYTHON), repo_root) - caller_abs = repo_root / _CALLER_REL_PATH - - with project_with_ls_context(Language.PYTHON, str(repo_root)) as project: - language_server = next(iter(project.language_server_manager.iter_language_servers())) - - def sync_fs() -> int: - return project.language_server_manager.sync_file_system_changes() - - # Warm the reference index, then establish the freshness baseline (first poll never notifies). - _referencing_symbol_names(language_server) - assert sync_fs() == 0, "first poll must only establish the baseline" - - # --- Created ------------------------------------------------------------------------------- - caller_abs.write_text(_caller_source(_CALLER_ONE), encoding="utf-8") - - # Adversarial: without the poll, the warm server has never seen the new file -> stale. - assert _CALLER_ONE not in _referencing_symbol_names(language_server), "expected the new caller to be invisible before poll" - - assert sync_fs() == 1, "one Created event expected" - assert _CALLER_ONE in _referencing_symbol_names(language_server), "new caller must be visible after poll" - - # --- Changed ------------------------------------------------------------------------------- - caller_abs.write_text(_caller_source(_CALLER_ONE, _CALLER_TWO), encoding="utf-8") - assert sync_fs() == 1, "one Changed event expected" - names_after_change = _referencing_symbol_names(language_server) - assert _CALLER_TWO in names_after_change, "appended caller must be visible after poll" - assert _CALLER_ONE in names_after_change - - # --- Deleted ------------------------------------------------------------------------------- - caller_abs.unlink() - assert sync_fs() == 1, "one Deleted event expected" - names_after_delete = _referencing_symbol_names(language_server) - assert _CALLER_ONE not in names_after_delete, "deleted caller must disappear after poll" - assert _CALLER_TWO not in names_after_delete +def test_ls_low_level_find_references_with_explicit_sync(tmp_path): + """ + Tests that requesting references directly from the LS works if synchronisation is explicitly requested after external file changes. + """ + FileSystemSyncTestCase(use_serena_tool=False).run(tmp_path) -def test_first_poll_is_baseline_only(tmp_path): +def test_sync_first_poll_is_baseline_only(tmp_path): """A newly-loaded project must not report its entire pre-existing file set as changed.""" repo_root = tmp_path / "repo" shutil.copytree(get_repo_path(Language.PYTHON), repo_root) From 574110fc286ddcaac8d15c6a256be59e853d76ce Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 21 Jul 2026 12:38:38 +0200 Subject: [PATCH 169/170] Initialise file change notifier (establishing baseline) upon construction --- src/serena/ls_manager.py | 7 ++++++- test/serena/test_ls_file_sync.py | 10 ---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 27218ded..45405962 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -280,12 +280,17 @@ class LanguageServerFileChangeNotifier: Detects changes to source files on disk and notifies language servers of those changes. """ - def __init__(self, project: "Project", language_server_manager: LanguageServerManager) -> None: + def __init__(self, project: "Project", language_server_manager: LanguageServerManager, initial_poll: bool = True) -> None: self._project = project self._language_server_manager = language_server_manager self._freshness_last_seen_mtimes: dict[str, float] | None = None self._freshness_lock = threading.Lock() + if initial_poll: + # Establish the baseline for the first poll; no notifications are sent on the first call. + with LogTime("Initialising file change notifier (polling for baseline)"): + self.poll_and_notify() + def poll_and_notify(self) -> int: """ Detects source files that were changed, created or deleted on disk since the last call diff --git a/test/serena/test_ls_file_sync.py b/test/serena/test_ls_file_sync.py index feeacc8c..874dbcc7 100644 --- a/test/serena/test_ls_file_sync.py +++ b/test/serena/test_ls_file_sync.py @@ -132,13 +132,3 @@ def test_ls_low_level_find_references_with_explicit_sync(tmp_path): Tests that requesting references directly from the LS works if synchronisation is explicitly requested after external file changes. """ FileSystemSyncTestCase(use_serena_tool=False).run(tmp_path) - - -def test_sync_first_poll_is_baseline_only(tmp_path): - """A newly-loaded project must not report its entire pre-existing file set as changed.""" - repo_root = tmp_path / "repo" - shutil.copytree(get_repo_path(Language.PYTHON), repo_root) - with project_with_ls_context(Language.PYTHON, str(repo_root)) as project: - assert project.ls_sync_file_system_changes() == 0 - # No external edits between the two calls -> still nothing to report. - assert project.ls_sync_file_system_changes() == 0 From 4cf2bf04e48694cfedce0393aa764cbaef75cc10 Mon Sep 17 00:00:00 2001 From: Dominik Jain <dominik.jain@oraios-ai.de> Date: Tue, 21 Jul 2026 12:42:38 +0200 Subject: [PATCH 170/170] Document tool file sync requirements, adding missing sync call in RenameSymbolTool --- src/serena/tools/symbol_tools.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 69d24d81..c4ed5bfc 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -54,6 +54,8 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): Don't adjust unless there is really no other way to get the content required for the task. :return: a JSON object containing symbols grouped by kind in a compact format. """ + # Note: file system sync not required (relevant file is opened in the language server explicitly) + if depth == -1: if relative_path.endswith((".java", ".kt")): depth = 1 @@ -189,6 +191,8 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): :param max_answer_chars: max result length; -1 for default :return: symbols (with locations) matching the name. """ + # Note: file system sync not required; the symbol finder opens all relevant source files explicitly in the case of changes + if include_body: depth = 0 # ignore user-specified depth if include_body is True assert max_matches != 0, "max_matches must be > 0 or equal to -1." @@ -685,6 +689,7 @@ class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): :param new_name: the new name for the symbol :return: result summary indicating success or failure """ + self.project.ls_sync_file_system_changes() code_editor = self.create_ls_code_editor() status_message = code_editor.rename_symbol(name_path, relative_path=relative_path, new_name=new_name) return status_message