From 7ea9317a95831d76ee5d1a402fe06833f40d7d65 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 22 Apr 2026 12:57:37 +0200 Subject: [PATCH 01/48] Removed no longer used set_modes and tests for it --- src/serena/agent.py | 12 ------ test/serena/test_set_modes.py | 75 ----------------------------------- 2 files changed, 87 deletions(-) delete mode 100644 test/serena/test_set_modes.py diff --git a/src/serena/agent.py b/src/serena/agent.py index 295a4add..9331b26d 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -744,18 +744,6 @@ class SerenaAgent: raise ValueError("No active project. Please activate a project first.") return project - def set_modes(self, mode_names: list[str]) -> None: - """ - Set the current mode configurations. - - :param mode_names: List of mode names or paths to use - """ - self._mode_overrides = ModeSelectionDefinition(default_modes=mode_names) - self._update_active_modes() - self._update_active_tools() - - log.info(f"Set modes to {[mode.name for mode in self.get_active_modes()]}") - def get_active_modes(self) -> list[SerenaAgentMode]: """ :return: the list of active modes diff --git a/test/serena/test_set_modes.py b/test/serena/test_set_modes.py deleted file mode 100644 index dadae4ba..00000000 --- a/test/serena/test_set_modes.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Tests for SerenaAgent.set_modes() to verify that mode switching works correctly.""" - -import logging - -from serena.agent import SerenaAgent -from serena.config.serena_config import ModeSelectionDefinition, SerenaConfig - - -class TestSetModes: - """Test that set_modes correctly changes active modes.""" - - def _create_agent(self, modes: ModeSelectionDefinition | None = None) -> SerenaAgent: - config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) - return SerenaAgent(serena_config=config, modes=modes) - - def test_set_modes_changes_active_modes(self) -> None: - """Test that calling set_modes actually changes the active modes.""" - agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"])) - - initial_mode_names = sorted(m.name for m in agent.get_active_modes()) - assert "editing" in initial_mode_names - assert "interactive" in initial_mode_names - - # Switch to planning mode - agent.set_modes(["planning", "interactive"]) - - new_mode_names = sorted(m.name for m in agent.get_active_modes()) - assert "planning" in new_mode_names - assert "interactive" in new_mode_names - assert "editing" not in new_mode_names - - def test_set_modes_overrides_config_defaults(self) -> None: - """Test that set_modes takes precedence over config defaults.""" - config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) - config.default_modes = ["editing", "interactive"] - agent = SerenaAgent(serena_config=config) - - # Verify config defaults are active - initial_mode_names = [m.name for m in agent.get_active_modes()] - assert "editing" in initial_mode_names - - # Switch modes — should override config defaults - agent.set_modes(["planning", "one-shot"]) - - new_mode_names = [m.name for m in agent.get_active_modes()] - assert "planning" in new_mode_names - assert "one-shot" in new_mode_names - assert "editing" not in new_mode_names - - def test_set_modes_persists_after_repeated_calls(self) -> None: - """Test that set_modes result persists (modes don't revert).""" - agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing"])) - - agent.set_modes(["planning"]) - mode_names_1 = [m.name for m in agent.get_active_modes()] - assert "planning" in mode_names_1 - - # Call get_active_modes again — should still be planning - mode_names_2 = [m.name for m in agent.get_active_modes()] - assert mode_names_1 == mode_names_2 - - def test_set_modes_can_switch_back(self) -> None: - """Test that modes can be switched back to original after switching away.""" - agent = self._create_agent(modes=ModeSelectionDefinition(default_modes=["editing", "interactive"])) - - # Switch away - agent.set_modes(["planning", "one-shot"]) - assert "planning" in [m.name for m in agent.get_active_modes()] - - # Switch back - agent.set_modes(["editing", "interactive"]) - mode_names = [m.name for m in agent.get_active_modes()] - assert "editing" in mode_names - assert "interactive" in mode_names - assert "planning" not in mode_names From ee6a926eabf0998bc0ff717e44e5ec119761a350 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 24 Apr 2026 17:00:45 +0200 Subject: [PATCH 02/48] Add news item for v1.2.0 release --- news/20260424.html | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 news/20260424.html diff --git a/news/20260424.html b/news/20260424.html new file mode 100644 index 00000000..e60a1f27 --- /dev/null +++ b/news/20260424.html @@ -0,0 +1,18 @@ +
+

Serena v1.2.0

+

April 24, 2026

+

+ Interactive Debugging. + With the Serena JetBrains plugin, + your agent can set now set breakpoints, inspect variables, evaluate expressions and control execution flow + by directly interacting with the IDE's debugger. + Our REPL-based interface is highly flexible, allowing all of this to be provided via a single tool. +

+

+ Update Now. The update also adds JSON as a new supported "language" for the LSP backend, + improvements in token efficiency/prompt provision, as well as a multitude of general improvements and fixes. + See our change log for details.
+ If you are using the uv tool installation of Serena, upgrade as follows:
+ uv tool upgrade serena-agent --prerelease=allow +

+
\ No newline at end of file From 26f4fcdc9f7da55f16c77336299324cf3e52d8c6 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 25 Apr 2026 19:17:02 +0200 Subject: [PATCH 03/48] CLI: make heavy imports local to accelerate light cli commands --- src/serena/cli.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 5e87fa2e..5001fb6d 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -18,7 +18,6 @@ from sensai.util.string import dict_string from tqdm import tqdm from serena import serena_version -from serena.agent import SerenaAgent from serena.config.client_setup import client_setup_handlers from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( @@ -36,9 +35,6 @@ from serena.constants import ( SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, ) -from serena.mcp import SerenaMCPFactory -from serena.project import Project -from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool, ToolRegistry from serena.util.cli_util import AutoRegisteringGroup from serena.util.dataclass import get_dataclass_default from serena.util.logging import MemoryLogHandler @@ -315,6 +311,8 @@ class TopLevelCommands(AutoRegisteringGroup): trace_lsp_communication: bool | None, tool_timeout: float | None, ) -> None: + from serena.mcp import SerenaMCPFactory + # initialize logging, using INFO level initially (will later be adjusted by SerenaAgent according to the config) # * memory log handler (for use by GUI/Dashboard) # * stream handler for stderr (for direct console output, which will also be captured by clients like Claude Desktop) @@ -346,6 +344,7 @@ class TopLevelCommands(AutoRegisteringGroup): log.warning("No project root found from %s; not activating any project", os.getcwd()) project_file = project_file_arg or project + factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( host=host, @@ -394,6 +393,8 @@ class TopLevelCommands(AutoRegisteringGroup): def print_system_prompt( project: str, log_level: str, 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." @@ -403,9 +404,14 @@ 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 + print(serena_config.default_modes) + print(serena_config.base_modes) + agent = SerenaAgent( project=os.path.abspath(project), - serena_config=SerenaConfig(web_dashboard=False, log_level=lvl), + serena_config=serena_config, context=context_instance, modes=modes_selection_def, ) @@ -828,6 +834,8 @@ class ProjectCommands(AutoRegisteringGroup): :param path: The path to check. :param project: The path to the project directory, defaults to the current working directory. """ + from serena.project import Project + serena_config = SerenaConfig.from_config_file() proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(path): @@ -851,6 +859,8 @@ class ProjectCommands(AutoRegisteringGroup): :param project: path to the project directory, defaults to the current working directory. :param verbose: if set, prints detailed information about the indexed symbols. """ + from serena.project import Project + serena_config = SerenaConfig.from_config_file() proj = Project.load(os.path.abspath(project), serena_config=serena_config) if os.path.isabs(file): @@ -887,6 +897,10 @@ class ProjectCommands(AutoRegisteringGroup): :param project: path to the project directory, defaults to the current working directory. """ # NOTE: completely written by Claude Code, only functionality was reviewed, not implementation + from serena.agent import SerenaAgent + from serena.project import Project + from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, GetSymbolsOverviewTool, SearchForPatternTool + logging.configure(level=logging.INFO) project_path = os.path.abspath(project) serena_config = SerenaConfig.from_config_file() @@ -1040,6 +1054,8 @@ class ToolCommands(AutoRegisteringGroup): @click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.") @click.option("--only-optional", is_flag=True, help="List only optional tools (those not enabled by default).") def list(quiet: bool = False, include_optional: bool = False, only_optional: bool = False) -> None: + from serena.tools import ToolRegistry + tool_registry = ToolRegistry() if quiet: if only_optional: @@ -1062,6 +1078,9 @@ class ToolCommands(AutoRegisteringGroup): @click.argument("tool_name", type=str) @click.option("--context", type=str, default=None, help="Context name or path to context file.") def description(tool_name: str, context: str | None = None) -> None: + from serena.agent import SerenaAgent + from serena.mcp import SerenaMCPFactory + # Load the context serena_context = None if context: From d3513077c8bf563ae9007325229b0c911a0a4c16 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 25 Apr 2026 19:18:08 +0200 Subject: [PATCH 04/48] CLI: added new command cc-system-prompt-override --- src/serena/cli.py | 10 ++ .../generated/generated_prompt_factory.py | 3 + .../config/prompt_templates/system_prompt.yml | 156 ++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/src/serena/cli.py b/src/serena/cli.py index 5001fb6d..9872ccf0 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -35,6 +35,7 @@ from serena.constants import ( SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, ) +from serena.prompt_factory import SerenaPromptFactory from serena.util.cli_util import AutoRegisteringGroup from serena.util.dataclass import get_dataclass_default from serena.util.logging import MemoryLogHandler @@ -491,6 +492,15 @@ class TopLevelCommands(AutoRegisteringGroup): viewer = SerenaDashboardViewer(url, width=width, height=height) viewer.run() + @staticmethod + @click.command( + "cc-system-prompt-override", + help="To be used specifically in Claude Code as value for `--system-prompt`", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) + def cc_system_prompt_override() -> None: + click.echo(SerenaPromptFactory().create_cc_system_prompt_override()) + class ModeCommands(AutoRegisteringGroup): """Group for 'mode' subcommands.""" diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 43f866d7..bb947cea 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -33,3 +33,6 @@ class PromptFactory(PromptFactoryBase): mode_system_prompts: Any, ) -> str: return self._render_prompt("system_prompt", locals()) + + def create_cc_system_prompt_override(self) -> str: + return self._render_prompt("cc_system_prompt_override", locals()) diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index 426ba0be..722d3a9d 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -58,3 +58,159 @@ prompts: {% endfor %} You have hereby read the 'Serena Instructions Manual' and do not need to read it again. + + cc_system_prompt_override: | + You are Claude Code, Anthropic's official CLI for Claude. You are an interactive + software-engineering agent. The user works with you through a terminal; your text + output is what they see, and your tool calls are what change the world. + + # Tool selection (read this before every tool call on a code file) + + This project uses Serena, an MCP server that exposes semantic, symbol-aware tools + for reading and editing code. Serena's tools are the PRIMARY tools for code work + in this project. The built-in Read, Glob, Grep, and Edit tools are SECONDARY and + must not be used on code files when a Serena equivalent exists. + + The built-in tool descriptions in your context will tell you things like "use Read + for a known path" and "prefer dedicated tools (Read, Edit, Write, Glob, Grep)". + Those descriptions are written for projects without Serena and are SUPERSEDED here. + When they conflict with this section, this section wins. Do not rationalize the + built-in tools with "the file is small," "I already know what I need," "this is + one call versus three," or "the path is known" — those rationalizations have + produced incorrect behavior before and are explicitly disallowed. + + ## Mapping (use the right column, not the left) + + Task Tool to use + -------------------------------------- ---------------------------------------- + See a code file's structure get_symbols_overview + Read a specific symbol's body find_symbol (include_body=true) + Find a symbol by name across the repo find_symbol + Find references / callers find_referencing_symbols + Find declarations / implementations find_declaration / _find_implementations + Edit a symbol's body replace_symbol_body + Insert near a symbol insert_before_symbol / _insert_after_symbol + Pattern replace inside a file replace_content + Rename / move / delete a symbol rename / _move / _safe_delete + Inline a symbol inline_symbol + Type hierarchy type_hierarchy + + Built-in Read/Edit/Glob/Grep are permitted on code files ONLY when: + - Serena has been tried on the target and failed, OR + - The file is not parseable as code (e.g., generated, malformed), OR + - You need a regex search across many files that Serena's symbolic tools cannot + express — in which case Grep is acceptable as a discovery step, but follow-up + reads/edits on matched code files must still go through Serena. + - You need to read a few lines and symbolic reads would be an overkill. + - You absolutely have to read the full file for some reason. + + Read/Edit/Glob are fine for non-code files: markdown, JSON, YAML, TOML, .env, + config files, lockfiles, plain text, images. + + ## Required workflow before editing code + + 1. get_symbols_overview on the target file (skip if already done this session). + 2. find_symbol with include_body=true for the specific symbols you'll touch. + Read only the symbols you need — not the whole file. + 3. Edit with replace_symbol_body, insert_before_symbol, insert_after_symbol, or + replace_content. Never use the built-in Edit on a code file when one of these + fits. + + ## Self-check + + Before every Read, Glob, Grep, or Edit call: "Does this target a code file, and + does the mapping above name a Serena tool for this task?" If yes, switch. Do this + check every time — not just once per session. + + # Doing tasks + + The user will ask you to fix bugs, add features, refactor, explain code, and + similar. Approach each task with these defaults: + + - Understand before changing. Use the symbolic tools to build a precise picture of + what's there, then make the smallest change that satisfies the request. + - Don't add scope. No surrounding cleanup on a bug fix, no abstractions for + hypothetical future needs, no error handling for cases that can't happen, no + feature flags or backwards-compat shims unless asked. Three similar lines beats + a premature abstraction. + - Don't write comments unless the WHY is non-obvious — a hidden constraint, a + workaround, a subtle invariant. Don't narrate WHAT the code does; well-named + identifiers handle that. Don't reference the current task or PR in comments. + - Prefer editing existing files to creating new ones. Never create *.md or README + files unless the user explicitly asks. + - For exploratory questions ("what could we do about X?"), reply in 2–3 sentences + with a recommendation and the main tradeoff. Don't implement until the user + agrees. + - For UI/frontend changes you can't test in a browser, say so explicitly rather + than claiming success. + - Watch for security issues (injection, XSS, SQL injection, path traversal, secret + leaks). Fix them when you spot them. + + # Executing actions with care + + Local, reversible actions (editing files, running tests, reading state) are free + to take. Pause and confirm before: + + - Destructive ops: deleting files/branches, dropping tables, killing processes, + rm -rf, overwriting uncommitted changes, git reset --hard, force-push. + - Hard-to-reverse ops: amending published commits, removing dependencies, + modifying CI/CD. + - Externally visible actions: pushing, opening/closing/commenting on PRs or + issues, sending messages, posting to third-party services. + - Uploading content to third-party tools (renderers, pastebins) — assume it's + public and may be cached. + + If you hit an obstacle, find the root cause. Don't bypass it with --no-verify, + --force, or by deleting the thing in your way. If you find unfamiliar files, + branches, or config, investigate before deleting — it may be the user's + in-progress work. + + A user approving an action once does not approve it forever. Match the scope of + your action to what was actually requested. + + # Git and commits + + - Only commit when the user asks. Never proactively. + - Never update git config. Never skip hooks (--no-verify, --no-gpg-sign) unless + the user explicitly asks. + - Prefer new commits over --amend. If a pre-commit hook fails, the commit didn't + happen — fix the issue, re-stage, and create a NEW commit (not --amend, which + would modify the previous commit). + - Stage files by name, not `git add -A` or `git add .` — those can sweep in + secrets or large binaries. + - Don't commit files that look like secrets (.env, credentials.json, *.pem). If + the user explicitly asks, warn first. + - For commit messages, use a HEREDOC to preserve formatting. End the trailer with: + Co-Authored-By: Claude Opus 4.7 (1M context) + - Don't push unless asked. Never force-push to main/master; warn if asked. + - For PRs, use `gh` via Bash. Look at the full diff against the base branch (not + just the latest commit) before drafting title/body. + + # Tone and output + + - Your tool calls aren't visible to the user — only your text is. Before your + first tool call, say in one sentence what you're about to do. While working, + give short updates at key moments: a finding, a direction change, a blocker. + Brief is good; silent is not. + - Don't narrate internal deliberation. State results and decisions; skip the + thinking-aloud. + - End-of-turn summary: one or two sentences max. What changed, what's next. + Nothing else. + - Match response shape to the task: a simple question gets a direct answer, not + headers and sections. + - No emojis unless the user asks. + - Use Github-flavored markdown. Reference code locations as `path:line` so the + user can jump. + + # Parallel tool calls + + When tool calls don't depend on each other, issue them in a single response. + When they do depend on each other, issue them sequentially with the dependent + values resolved. Don't use placeholders or guess. + + # Asking for help vs. acting + + When a request is ambiguous in a way that materially changes the work, ask one + focused question. When it's only ambiguous in ways that don't change the work, + pick the reasonable interpretation and proceed — and say which interpretation + you picked. From d1e337f92410a4deee2307a6c544d63ec3c5bcb6 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 25 Apr 2026 19:19:34 +0200 Subject: [PATCH 05/48] Extended claude-code context Adding more instructions and stressing them even more. The extension was suggested by claude code, then edited by me. This alone is not enough at the time of writing, the system prompt needs to be overridden for serena to be called consistently --- .../resources/config/contexts/claude-code.yml | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/serena/resources/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml index fe63878b..e6b27b7d 100644 --- a/src/serena/resources/config/contexts/claude-code.yml +++ b/src/serena/resources/config/contexts/claude-code.yml @@ -3,14 +3,35 @@ 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. - **IMPORTANT**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them - right now. + **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). - **IMPORTANT**: Never use a tool before having read its schema via the tool-search tool! + 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 From f4b035698c228c271dd0f314ef214ef76d052e1a Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 25 Apr 2026 19:37:33 +0200 Subject: [PATCH 06/48] Docs: added explanation on system prompt override for CC --- docs/02-usage/030_clients.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index c65bfe0b..ba12e29e 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -101,9 +101,29 @@ You can do this in Tools / GitHub Copilot / Chat, where at the bottom you can cl ## Claude Code -Serena is a great way to make Claude Code both cheaper and more powerful! +Serena is a great way to make Claude Code both more efficient and more powerful! To add Serena to Claude Code, you can simply run `serena setup claude-code`. Alternatively, follow the instructions below. +:::{attention} +Recent updates to Claude Code (CC) and to the Opus line of models resulted in drastically poorer +adherence to instructions pertaining to Serena's tools. + +After extensive analysis, we identified part of the reason to be very long and detailed +tool descriptions for inbuilt tools and parts of the default system prompt. +The descriptions of CC's system tools take almost 16k tokens, cannot be adjusted by the user, +and introduce a very strong bias towards internal tools, making it near impossible to convince Opus 4.7 to use Serena. + +As a workaround, we crafted a system prompt that counteracts this bias. +When using Serena, we highly recommend that you start CC as + +```shell +claude --system-prompt=$(serena cc-system-prompt-override) +``` + +You can also consider adding the content of `serena cc-system-prompt-override` to your `CLAUDE.md` files, +but the effect be insufficient for counteracting Claude Code's bias towards internal tools. +::: + **Global Configuration**. To add the Serena MCP server for all your projects, use the user-level configuration of claude code and the `--project-from-cwd` flag: ```shell From 1a83060b05d31ca322b8f49e175f1bec6d5402e4 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 26 Apr 2026 16:25:58 +0200 Subject: [PATCH 07/48] Extended interprompt and moved cc-system-prompt-override command to prompts subcommand Also added print-prompt-template subcommand --- CHANGELOG.md | 8 ++++- docs/02-usage/030_clients.md | 2 +- src/interprompt/jinja_template.py | 3 ++ src/interprompt/multilang_prompt.py | 3 ++ src/interprompt/prompt_factory.py | 17 +++++++---- src/serena/cli.py | 29 +++++++++++++------ .../generated/generated_prompt_factory.py | 16 +++++++++- 7 files changed, 61 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aea6203e..eb7bd2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,6 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: - - Support `serena --version` CLI command for displaying the current version #1347 - Fix: Check for ignored path ignored `.git` folder only at the top level, not in every subdirectory (`Project._is_ignored_relative_path`) #1350 - `GetSymbolsOverviewTool`: ignored paths were not respected in LSP variant (fix in `SolidLanguageServer`) - Fix: Duplicate comments in re-saved YAML configuration files #1285 @@ -31,6 +30,13 @@ Status of the `main` branch. Changes prior to the next official version change w The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean. - Add `serena_info` tool for on-demand retrieval of usage information +* CLI: + - Support `serena --version` CLI command for displaying the current version #1347 + - Extend `prompts` subcommand with `print-prompt-template` and `print-cc-system-prompt-override`. + +* Clients: + - Document workaround to make Claude Code use Serena's tools after recent degradations caused by changes in CC harness and Opus 4.7 release. + * JetBrains: - Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility. diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index ba12e29e..b1946bb7 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -117,7 +117,7 @@ As a workaround, we crafted a system prompt that counteracts this bias. When using Serena, we highly recommend that you start CC as ```shell -claude --system-prompt=$(serena cc-system-prompt-override) +claude --system-prompt="$(serena prompts print-cc-system-prompt-override)" ``` You can also consider adding the content of `serena cc-system-prompt-override` to your `CLAUDE.md` files, diff --git a/src/interprompt/jinja_template.py b/src/interprompt/jinja_template.py index 63f4fd87..32220155 100644 --- a/src/interprompt/jinja_template.py +++ b/src/interprompt/jinja_template.py @@ -30,6 +30,9 @@ class JinjaTemplate(ParameterizedTemplateInterface): parsed_content = self._template.environment.parse(self._template_string) self._parameters = sorted(jinja2.meta.find_undeclared_variables(parsed_content)) + def get_template_string(self) -> str: + return self._template_string + def render(self, **params: Any) -> str: """Renders the template with the given kwargs. You can find out which parameters are required by calling get_parameter_names().""" return self._template.render(**params) diff --git a/src/interprompt/multilang_prompt.py b/src/interprompt/multilang_prompt.py index e7322efb..3c84ced8 100644 --- a/src/interprompt/multilang_prompt.py +++ b/src/interprompt/multilang_prompt.py @@ -19,6 +19,9 @@ class PromptTemplate(ToStringMixin, ParameterizedTemplateInterface): def _tostring_exclude_private(self) -> bool: return True + def get_template_string(self) -> str: + return self._jinja_template.get_template_string() + def render(self, **params: Any) -> str: return self._jinja_template.render(**params) diff --git a/src/interprompt/prompt_factory.py b/src/interprompt/prompt_factory.py index 1769e7f2..9ad6ab7c 100644 --- a/src/interprompt/prompt_factory.py +++ b/src/interprompt/prompt_factory.py @@ -2,7 +2,7 @@ import logging import os from typing import Any -from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList +from .multilang_prompt import DEFAULT_LANG_CODE, LanguageFallbackMode, MultiLangPromptCollection, PromptList, PromptTemplate log = logging.getLogger(__name__) @@ -23,9 +23,15 @@ class PromptFactoryBase: self.lang_code = lang_code self._prompt_collection = MultiLangPromptCollection(prompts_dir, fallback_mode=fallback_mode) + def _get_prompt_template(self, prompt_name: str) -> PromptTemplate: + return self._prompt_collection.get_prompt_template(prompt_name, lang_code=self.lang_code) + + def get_prompt_template_string(self, prompt_name: str) -> str: + return self._get_prompt_template(prompt_name).get_template_string() + def _render_prompt(self, prompt_name: str, params: dict[str, Any]) -> str: del params["self"] - return self._prompt_collection.render_prompt_template(prompt_name, params, lang_code=self.lang_code) + return self._get_prompt_template(prompt_name).render(**params) def _get_prompt_list(self, prompt_name: str) -> PromptList: return self._prompt_collection.get_prompt_list(prompt_name, self.lang_code) @@ -41,14 +47,12 @@ def autogenerate_prompt_factory_module(prompts_dir: str, target_module_path: str :param prompts_dir: the directory containing the prompt templates and prompt lists :param target_module_path: the path to the target module file (.py). Important: The module will be overwritten! """ - generated_code = """ -# ruff: noqa + generated_code = """# ruff: noqa # black: skip # mypy: ignore-errors # NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually! -from interprompt.multilang_prompt import PromptList from interprompt.prompt_factory import PromptFactoryBase from typing import Any @@ -68,6 +72,9 @@ class PromptFactory(PromptFactoryBase): else: method_params_str = ", *, " + ", ".join([f"{param}: Any" for param in template_parameters]) generated_code += f""" + def get_{template_name}_template_string(self) -> str: + return self.get_prompt_template_string('{template_name}') + def create_{template_name}(self{method_params_str}) -> str: return self._render_prompt('{template_name}', locals()) """ diff --git a/src/serena/cli.py b/src/serena/cli.py index 9872ccf0..6d25a35b 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -492,15 +492,6 @@ class TopLevelCommands(AutoRegisteringGroup): viewer = SerenaDashboardViewer(url, width=width, height=height) viewer.run() - @staticmethod - @click.command( - "cc-system-prompt-override", - help="To be used specifically in Claude Code as value for `--system-prompt`", - context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, - ) - def cc_system_prompt_override() -> None: - click.echo(SerenaPromptFactory().create_cc_system_prompt_override()) - class ModeCommands(AutoRegisteringGroup): """Group for 'mode' subcommands.""" @@ -1200,6 +1191,26 @@ class PromptCommands(AutoRegisteringGroup): os.remove(user_prompt_yaml_path) click.echo(f"Deleted override file '{prompt_yaml_name}'.") + @staticmethod + @click.command( + "print-prompt-template", + help="prints the (unrendered) template for the corresponding prompt name. " + "This respects custom prompt yaml overrides and thus will print the value that will be used in Serena", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) + @click.argument("prompt_name", type=str) + def print_prompt_template(prompt_name: str) -> None: + click.echo(SerenaPromptFactory().get_prompt_template_string(prompt_name)) + + @staticmethod + @click.command( + "print-cc-system-prompt-override", + help="To be used specifically in Claude Code as value for `--system-prompt`", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) + def print_cc_system_prompt_override() -> None: + click.echo(SerenaPromptFactory().get_cc_system_prompt_override_template_string()) + _mode = ModeCommands() _context = ContextCommands() diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index bb947cea..a1ee2920 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -4,7 +4,6 @@ # NOTE: This module is auto-generated from interprompt.autogenerate_prompt_factory_module, do not edit manually! -from interprompt.multilang_prompt import PromptList from interprompt.prompt_factory import PromptFactoryBase from typing import Any @@ -14,15 +13,27 @@ class PromptFactory(PromptFactoryBase): A class for retrieving and rendering prompt templates and prompt lists. """ + def get_info_jet_brains_debug_repl_template_string(self) -> str: + return self.get_prompt_template_string("info_jet_brains_debug_repl") + def create_info_jet_brains_debug_repl(self) -> str: return self._render_prompt("info_jet_brains_debug_repl", locals()) + def get_onboarding_prompt_template_string(self) -> str: + return self.get_prompt_template_string("onboarding_prompt") + def create_onboarding_prompt(self, *, system: Any) -> str: return self._render_prompt("onboarding_prompt", locals()) + def get_connection_prompt_template_string(self) -> str: + return self.get_prompt_template_string("connection_prompt") + def create_connection_prompt(self) -> str: return self._render_prompt("connection_prompt", locals()) + def get_system_prompt_template_string(self) -> str: + return self.get_prompt_template_string("system_prompt") + def create_system_prompt( self, *, @@ -34,5 +45,8 @@ class PromptFactory(PromptFactoryBase): ) -> str: return self._render_prompt("system_prompt", locals()) + def get_cc_system_prompt_override_template_string(self) -> str: + return self.get_prompt_template_string("cc_system_prompt_override") + def create_cc_system_prompt_override(self) -> str: return self._render_prompt("cc_system_prompt_override", locals()) From d4c75224d349aa44e5eef90f5f7bad62abb2ebc2 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 26 Apr 2026 16:26:30 +0200 Subject: [PATCH 08/48] Added demo script for profiling/debugging the CLI --- scripts/demo_cli_call.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 scripts/demo_cli_call.py diff --git a/scripts/demo_cli_call.py b/scripts/demo_cli_call.py new file mode 100644 index 00000000..9639a9a8 --- /dev/null +++ b/scripts/demo_cli_call.py @@ -0,0 +1,4 @@ +from serena.cli import top_level + +if __name__ == "__main__": + top_level(["--help"]) From ae7f1065be5bea27ade3d1279abef5d13eba151b Mon Sep 17 00:00:00 2001 From: a-simeshin <89605031+a-simeshin@users.noreply.github.com> Date: Sun, 26 Apr 2026 17:28:31 +0300 Subject: [PATCH 09/48] ci: install libuv1-dev so R `fs` package builds on ubuntu-24.04 (#1420) The `Install R language server` step started failing on the new `ubuntu-24.04` runner image (`ubuntu-latest`) because the R `fs` package falls back to a source build and its `configure` step calls `pkg-config --exists libuv`, which fails with: Package libuv was not found in the pkg-config search path. fatal error: uv.h: No such file or directory ERROR: configuration failed for package 'fs' That cascades through `pkgload` -> `roxygen2` -> `languageserver`, making `test/solidlsp/r/test_r_basic.py` error out at setup with `RuntimeError: R languageserver package is not installed`. Verified locally on a clean `ubuntu:24.04` container: `libuv1-dev` is not preinstalled but is available in the `noble` apt repo (version `1.48.0-1.1build1`). Installing it makes `pkg-config --exists libuv` succeed (`pkg-config --modversion libuv` returns `1.48.0`), which is exactly what the `fs` configure script needs. Linux-only via `if: runner.os == 'Linux'` so the step is a no-op on macOS and Windows runners (libuv is bundled differently there and `fs` is delivered as a binary). Refs: #1419 --- .github/workflows/pytest.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 60530151..86246a2e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -224,6 +224,10 @@ jobs: 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: | From f018b4baf676db7f72735b38b6bac45d1b6030e4 Mon Sep 17 00:00:00 2001 From: Darin Morrison Date: Fri, 24 Apr 2026 13:05:09 -0600 Subject: [PATCH 10/48] Improve cpp log level classification --- .../clangd_language_server.py | 33 ++++++++++++++ test/solidlsp/cpp/test_clangd_logging.py | 43 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 test/solidlsp/cpp/test_clangd_logging.py diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index 0dc5ed9c..7b7d6839 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -34,6 +34,39 @@ class ClangdLanguageServer(SolidLanguageServer): (default: the bundled Serena version). """ + @staticmethod + def _determine_log_level(line: str) -> int: + """ + Classify a clangd stderr line using clangd's explicit level prefix. + + See `clang::clangd::Logger::indicator` for details: + https://clang.llvm.org/extra/doxygen/classclang_1_1clangd_1_1Logger.html + + Clangd emits each log record prefixed by a single indicator character + followed by a timestamp in square brackets, e.g. ``I[12:27:16.234]``. + + The indicators are ``D`` (Debug), ``I`` (Info), ``E`` (Error) and + ``F`` (Fatal). Continuation lines of multi-line records carry no + prefix and are treated as informational. + + Without this override, the base implementation scans the line for + the substrings ``error`` and ``exception``, which produces false + positives on clangd's reconstructed compile commands in some cases + (e.g. ``-DNO_EXCEPTIONS``, ``-fno-exceptions``). + """ + # classify by clangd's level indicator character + if len(line) >= 2 and line[1] == "[": + indicator = line[0] + if indicator in ("E", "F"): + return logging.ERROR + if indicator == "I": + return logging.INFO + if indicator == "D": + return logging.DEBUG + + # continuation line or non-prefixed output: default to INFO, do not keyword-scan + return logging.INFO + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. diff --git a/test/solidlsp/cpp/test_clangd_logging.py b/test/solidlsp/cpp/test_clangd_logging.py new file mode 100644 index 00000000..8cef777e --- /dev/null +++ b/test/solidlsp/cpp/test_clangd_logging.py @@ -0,0 +1,43 @@ +import logging + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer + + +@pytest.mark.cpp +class TestClangdLogging: + @pytest.mark.parametrize( + "expected, line", + [ + # Parametrize names instead of codes for human-readable output in test logs. + (logging.getLevelName(logging.INFO), "I[12:00:00.000] clangd version 22.1.3"), + (logging.getLevelName(logging.INFO), "V[12:00:00.000] verbose detail"), + (logging.getLevelName(logging.DEBUG), "D[12:00:00.000] debug detail"), + (logging.getLevelName(logging.ERROR), "E[12:00:00.000] something bad"), + (logging.getLevelName(logging.ERROR), "F[12:00:00.000] fatal"), + ], + ) + def test_log_record_detection(self, expected: str, line: str) -> None: + """ + Verifies clangd's stderr log classifier maps each level prefix to the + expected Python logging level and avoids misclassifying compile-command + payloads as ERROR. + """ + assert ClangdLanguageServer._determine_log_level(line) == logging.getLevelNamesMapping()[expected] + + def test_log_record_info_payload_mentions_fno_exceptions(self) -> None: + line = """clang++ -fno-exceptions -c no_exceptions.cpp""" + # Basic classifier incorrectly detects line as an error log. + assert SolidLanguageServer._determine_log_level(line) == logging.ERROR + # Clangd classifier correctly detects line as an info log. + assert ClangdLanguageServer._determine_log_level(line) == logging.INFO + + def test_log_record_info_payload_without_prefix(self) -> None: + # Multi-line records: first line carries the I[...] prefix, subsequent + # lines do not. Treat lack of prefix as continuation. + assert ClangdLanguageServer._determine_log_level("[/home/user/project/build]") == logging.INFO + + def test_log_record_info_payload_empty(self) -> None: + assert ClangdLanguageServer._determine_log_level("") == logging.INFO From 621ca5cf44c246c10298937af135ab27fc788891 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 27 Apr 2026 12:19:36 +0200 Subject: [PATCH 11/48] Interprompt: Add location to prompt templates, allow to retrieve template --- src/interprompt/multilang_prompt.py | 8 +++++--- src/interprompt/prompt_factory.py | 9 ++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/interprompt/multilang_prompt.py b/src/interprompt/multilang_prompt.py index 3c84ced8..ba953b4c 100644 --- a/src/interprompt/multilang_prompt.py +++ b/src/interprompt/multilang_prompt.py @@ -12,8 +12,9 @@ log = logging.getLogger(__name__) class PromptTemplate(ToStringMixin, ParameterizedTemplateInterface): - def __init__(self, name: str, jinja_template_string: str) -> None: + def __init__(self, name: str, jinja_template_string: str, path: str) -> None: self.name = name + self.path = path self._jinja_template = JinjaTemplate(jinja_template_string.strip()) def _tostring_exclude_private(self) -> bool: @@ -252,6 +253,7 @@ class MultiLangPromptCollection: self, name: str, template_str: str, + path: str, lang_code: str = DEFAULT_LANG_CODE, on_name_collision: Literal["skip", "overwrite", "raise"] = "raise", ) -> None: @@ -262,7 +264,7 @@ class MultiLangPromptCollection: :param on_name_collision: how to deal with name/lang_code collisions """ allow_overwrite = False - prompt_template = PromptTemplate(name, template_str) + prompt_template = PromptTemplate(name, template_str, path=path) mlpt = self._multi_lang_prompt_templates.get(name) if mlpt is None: mlpt = MultiLangPromptTemplate(name) @@ -330,7 +332,7 @@ class MultiLangPromptCollection: self._add_prompt_list(prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision) elif isinstance(prompt_template_or_list, str): self._add_prompt_template( - prompt_name, prompt_template_or_list, lang_code=lang_code, on_name_collision=on_name_collision + prompt_name, prompt_template_or_list, lang_code=lang_code, path=path, on_name_collision=on_name_collision ) else: raise ValueError( diff --git a/src/interprompt/prompt_factory.py b/src/interprompt/prompt_factory.py index 9ad6ab7c..647b0ed2 100644 --- a/src/interprompt/prompt_factory.py +++ b/src/interprompt/prompt_factory.py @@ -23,15 +23,18 @@ class PromptFactoryBase: self.lang_code = lang_code self._prompt_collection = MultiLangPromptCollection(prompts_dir, fallback_mode=fallback_mode) - def _get_prompt_template(self, prompt_name: str) -> PromptTemplate: + def get_prompt_names(self) -> list[str]: + return self._prompt_collection.get_prompt_template_names() + + def get_prompt_template(self, prompt_name: str) -> PromptTemplate: return self._prompt_collection.get_prompt_template(prompt_name, lang_code=self.lang_code) def get_prompt_template_string(self, prompt_name: str) -> str: - return self._get_prompt_template(prompt_name).get_template_string() + return self.get_prompt_template(prompt_name).get_template_string() def _render_prompt(self, prompt_name: str, params: dict[str, Any]) -> str: del params["self"] - return self._get_prompt_template(prompt_name).render(**params) + return self.get_prompt_template(prompt_name).render(**params) def _get_prompt_list(self, prompt_name: str) -> PromptList: return self._prompt_collection.get_prompt_list(prompt_name, self.lang_code) From 2382399b9237bb885f099192b0a48c4a28be45fb Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 27 Apr 2026 12:19:49 +0200 Subject: [PATCH 12/48] Improve prompt list CLI function --- CHANGELOG.md | 2 +- src/serena/cli.py | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7bd2bc..8e85a398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ Status of the `main` branch. Changes prior to the next official version change w * CLI: - Support `serena --version` CLI command for displaying the current version #1347 - - Extend `prompts` subcommand with `print-prompt-template` and `print-cc-system-prompt-override`. + - Extend `prompts` subcommand with `print-prompt-template` and `print-cc-system-prompt-override`, improve `list` subcommand * Clients: - Document workaround to make Claude Code use Serena's tools after recent degradations caused by changes in CC harness and Opus 4.7 release. diff --git a/src/serena/cli.py b/src/serena/cli.py index 6d25a35b..e7407545 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -1109,16 +1109,26 @@ class PromptCommands(AutoRegisteringGroup): @staticmethod @click.command( - "list", help="Lists yamls that are used for defining prompts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} + "list", help="Lists prompt names and YAML files that can be overridden.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} ) def list() -> None: + # list prompt names + click.echo("Prompts:") + factory = SerenaPromptFactory() + for key in factory.get_prompt_names(): + template = factory.get_prompt_template(key) + is_overridden = not template.path.startswith(PROMPT_TEMPLATES_DIR_INTERNAL) + click.echo(f" * '{key}' ({template.path if is_overridden else 'default'})") + + # list prompts files + click.echo("\nPrompt files (which you can override with the create-override command):") serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] for prompt_yaml_name in serena_prompt_yaml_names: user_prompt_yaml_path = PromptCommands._get_user_prompt_yaml_path(prompt_yaml_name) if os.path.exists(user_prompt_yaml_path): - click.echo(f"{user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}") + click.echo(f" * {user_prompt_yaml_path} merged with default prompts in {prompt_yaml_name}") else: - click.echo(prompt_yaml_name) + click.echo(f" * {prompt_yaml_name}") @staticmethod @click.command( @@ -1209,7 +1219,7 @@ class PromptCommands(AutoRegisteringGroup): context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) def print_cc_system_prompt_override() -> None: - click.echo(SerenaPromptFactory().get_cc_system_prompt_override_template_string()) + click.echo(SerenaPromptFactory().create_cc_system_prompt_override()) _mode = ModeCommands() From fd3e29ac50e2c9e88ccf5d7f10dc4518c666afa1 Mon Sep 17 00:00:00 2001 From: Rabbit_Slayer <43283626+leavesprior@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:39:34 -0700 Subject: [PATCH 13/48] Skip and warn on malformed project configs at startup instead of crashing the MCP server (#1424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Skip and warn on malformed project configs at startup `SerenaConfig.from_config_file` iterates the `projects:` list in `serena_config.yml` and calls `ProjectConfig.load(path)` with no exception handling. A single malformed `.serena/project.yml` (missing required key, bad language string, YAML syntax error, etc.) raises out of the master loader and aborts MCP server startup before any client handshake or tool registration. Other projects in the list — even ones that would load cleanly — become unreachable. This matches the failure mode behind #997: the schema-migration shim closed the *cause*, but the surrounding loader still has no isolation, so the next class of `project.yml` malformation will take the server down again. Wrap the `ProjectConfig.load(path)` call in the master loop with a narrow try/except that logs a warning and continues, matching the existing skip-with-warning pattern used a few lines above for inaccessible paths and missing project.yml files. One bad project becomes one skipped project, not a server-down event. A new test under `TestSerenaConfigFromConfigFileRobustness` registers two projects (one healthy, one with invalid YAML), asserts that `from_config_file` returns successfully, that only the healthy project ends up registered, and that a warning naming the bad path is logged. --------- Co-authored-by: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> --- src/serena/config/serena_config.py | 13 +++++- test/serena/config/test_serena_config.py | 56 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 5c8e4e10..329e0e86 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -869,7 +869,18 @@ class SerenaConfig(SharedConfig): if path is None: continue num_migrations += 1 - project_config = ProjectConfig.load(path, serena_config=instance) # instance is sufficiently populated + try: + project_config = ProjectConfig.load(path, serena_config=instance) # instance is sufficiently populated + except Exception as e: + log.error( + "Failed to load project configuration for %s: %s. " + "This project will be skipped. Fix or delete its " + ".serena/project.yml (or remove it from " + "serena_config.yml) to re-enable it.", + path, + e, + ) + continue project = RegisteredProject( project_root=str(path), project_config=project_config, diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 3f2e7ac3..75c1e143 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -499,6 +499,62 @@ class TestProjectSerenaDataFolder: assert project.path_to_serena_data_folder() == str(custom_serena) +class TestSerenaConfigFromConfigFileRobustness: + """Tests that ``SerenaConfig.from_config_file`` does not abort the whole + loader when a single registered project has a broken ``project.yml``. + """ + + def setup_method(self): + self.test_dir = Path(tempfile.mkdtemp()) + self.master_config_path = self.test_dir / "serena_config.yml" + + def teardown_method(self): + shutil.rmtree(self.test_dir) + + def _make_project_dir(self, name: str, project_yml_body: 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(project_yml_body) + return project_dir + + def _write_master_config(self, project_paths: list[Path]) -> None: + body_lines = ["projects:"] + for p in project_paths: + body_lines.append(f" - {p}") + self.master_config_path.write_text("\n".join(body_lines) + "\n") + + def test_malformed_project_is_skipped_with_warning(self, caplog, monkeypatch): + """A malformed project.yml must not abort loading of the others.""" + good_project = self._make_project_dir( + "good_project", + 'project_name: "good_project"\nlanguages: ["python"]\n', + ) + # Invalid YAML: a stray colon at the start of a mapping value. + bad_project = self._make_project_dir( + "bad_project", + ": this is not : valid : yaml :\n", + ) + self._write_master_config([good_project, bad_project]) + + # SerenaPaths is a process-wide singleton, so we cannot reliably + # redirect it via SERENA_HOME after the fact. Instead, redirect the + # config-file-path resolver directly. + 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()}, f"Expected only the good project to be registered, got {registered_roots}" + assert any("Failed to load project configuration" in msg and str(bad_project.resolve()) in msg for msg in caplog.messages), ( + f"Expected a warning naming {bad_project.resolve()}, got: {caplog.messages}" + ) + + class TestMemoriesManagerCustomPath: """Tests for MemoriesManager with a custom serena data folder.""" From c68137e4d4894ceb55105006ff2f2b57ca226e5f Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 27 Apr 2026 13:13:40 +0200 Subject: [PATCH 14/48] Adjusted wording of startup hook, improving project activation instructions #1401 --- CHANGELOG.md | 5 ++++- src/serena/hooks.py | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aea6203e..e6465a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,11 +50,14 @@ Status of the `main` branch. Changes prior to the next official version change w - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 -Dashboard: +* Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): Three modes (browser, native app with tray, tray manager for aggregating multiple instances) are supported, depending on the OS - Fix: Memory leaks in frontend when using Chromium-based browsers/Windows webview #1389 +* Hooks: + - Adjusted wording of startup hook, improving project activation instructions #1401. + # v1.1.2 (2026-04-14) * General: diff --git a/src/serena/hooks.py b/src/serena/hooks.py index 4a0ff12f..a9322193 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -339,8 +339,9 @@ class PreToolUseRemindAboutSerenaHook(PreToolUseHook): class SessionStartActivateProjectHook(Hook): def execute(self) -> None: message = ( - "**IMPORTANT**: Activate the current working directory as project using Serena's tools if not already done. " - "If you haven't read Serena Instructions Manual yet, do so. " + "**IMPORTANT**: If the current directory is a coding project you are working on:" + " 1. activate it using Serena's activate_project tool unless already done. " + " 2. if you haven't read Serena Instructions Manual yet, do so. " "Follow this instruction before doing anything else." ) result = { From d9864bcdc0f0e8053fd6647f8d4fe4b8992df5ad Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 27 Apr 2026 13:16:20 +0200 Subject: [PATCH 15/48] Update news for 1.2.0 --- news/{20260424.html => 20260427.html} | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) rename news/{20260424.html => 20260427.html} (60%) diff --git a/news/20260424.html b/news/20260427.html similarity index 60% rename from news/20260424.html rename to news/20260427.html index e60a1f27..0cdba681 100644 --- a/news/20260424.html +++ b/news/20260427.html @@ -1,6 +1,6 @@

Serena v1.2.0

-

April 24, 2026

+

April 27, 2026

Interactive Debugging. With the Serena JetBrains plugin, @@ -9,9 +9,18 @@ Our REPL-based interface is highly flexible, allowing all of this to be provided via a single tool.

- Update Now. The update also adds JSON as a new supported "language" for the LSP backend, + Counteracting Claude Code Regressions. + We analysed the issues causing Claude Code to use external tools increasingly suboptimally and implemented + another workaround (in addition to the existing hooks): a system prompt override. + Read our updated Claude code usage instructions for more information. +

+

+ The update also adds JSON as a new supported "language" for the LSP backend, improvements in token efficiency/prompt provision, as well as a multitude of general improvements and fixes. See our change log for details.
+

+

+ Update Now. If you are using the uv tool installation of Serena, upgrade as follows:
uv tool upgrade serena-agent --prerelease=allow

From 7c50041d149fe0f6980ebe6add0b34a27547907b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 27 Apr 2026 13:32:23 +0200 Subject: [PATCH 16/48] Improve Claude Code client setup documentation --- docs/02-usage/030_clients.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index b1946bb7..646873b1 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -102,16 +102,20 @@ You can do this in Tools / GitHub Copilot / Chat, where at the bottom you can cl ## Claude Code Serena is a great way to make Claude Code both more efficient and more powerful! -To add Serena to Claude Code, you can simply run `serena setup claude-code`. Alternatively, follow the instructions below. +To set up the Serena MCP server for Claude Code, you can simply run this command: + + serena setup claude-code + +Find manual setup instructions as well as workarounds for Claude Code's recent regressions pertaining to (external) tool use below. :::{attention} -Recent updates to Claude Code (CC) and to the Opus line of models resulted in drastically poorer +Recent updates to Claude Code (CC) and to the Opus line of models resulted in drastically reduced adherence to instructions pertaining to Serena's tools. After extensive analysis, we identified part of the reason to be very long and detailed -tool descriptions for inbuilt tools and parts of the default system prompt. +tool descriptions for built-in tools and parts of the default system prompt. The descriptions of CC's system tools take almost 16k tokens, cannot be adjusted by the user, -and introduce a very strong bias towards internal tools, making it near impossible to convince Opus 4.7 to use Serena. +and introduce a very strong bias towards internal tools, making it almost impossible to convince Opus 4.7 to use Serena. As a workaround, we crafted a system prompt that counteracts this bias. When using Serena, we highly recommend that you start CC as From 0a96f71bd90cc11ef4f35b8012f6710b03806da4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 27 Apr 2026 13:36:19 +0200 Subject: [PATCH 17/48] Release v1.2.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 99aed796..6747ece8 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.2.0 (2026-04-27) + * General: - Fix: Check for ignored path ignored `.git` folder only at the top level, not in every subdirectory (`Project._is_ignored_relative_path`) #1350 - `GetSymbolsOverviewTool`: ignored paths were not respected in LSP variant (fix in `SolidLanguageServer`) diff --git a/pyproject.toml b/pyproject.toml index 837800e8..35c00d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["hatchling"] [project] name = "serena-agent" -version = "1.1.2" +version = "1.2.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 ce2b1abf..1a89dded 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.1.2" +__version__ = "1.2.0" import logging diff --git a/uv.lock b/uv.lock index 659b9257..28f8baca 100644 --- a/uv.lock +++ b/uv.lock @@ -2856,7 +2856,7 @@ wheels = [ [[package]] name = "serena-agent" -version = "1.1.2" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, From 3a8393bcaff2c2f54053733e3cd9d0d611c16cc3 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 28 Apr 2026 09:26:26 +0200 Subject: [PATCH 18/48] Tests: removed some unnecessary hook tests --- test/serena/test_hooks.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py index 25408f0d..24af5523 100644 --- a/test/serena/test_hooks.py +++ b/test/serena/test_hooks.py @@ -13,7 +13,6 @@ from serena.hooks import ( PreToolUseAutoApproveSerenaHook, PreToolUseRemindAboutSerenaHook, SessionEndCleanupHook, - SessionStartActivateProjectHook, hook_commands, ) @@ -479,19 +478,6 @@ class TestPreToolUseAutoApproveSerenaHook: assert capsys.readouterr().out == "" -class TestSessionStartActivateProjectHook: - def test_outputs_activation_message(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): - stdin_data = {"session_id": "s1"} - with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): - SessionStartActivateProjectHook(HookClient.VSCODE).execute() - - output = capsys.readouterr().out.strip() - result = json.loads(output) - context = result["hookSpecificOutput"]["additionalContext"] - assert "Activate" in context - assert "Serena Instructions Manual" in context - - class TestSessionEndCleanupHook: def test_removes_session_dir(self, tmp_path: Path): session_dir = tmp_path / "hook_data" / "cleanup-session" @@ -515,15 +501,6 @@ class TestSessionEndCleanupHook: class TestHookCli: """Tests for the Click CLI entry point (serena-hooks).""" - def test_activate_command(self, tmp_path: Path): - stdin_json = json.dumps({"session_id": "cli-test"}) - runner = CliRunner() - with patch("serena.hooks.serena_home_dir", str(tmp_path)): - result = runner.invoke(hook_commands, ["activate", "--client", "vscode"], input=stdin_json) - assert result.exit_code == 0 - output = json.loads(result.output) - assert "Activate" in output["hookSpecificOutput"]["additionalContext"] - def test_cleanup_command(self, tmp_path: Path): session_dir = tmp_path / "hook_data" / "cli-cleanup" session_dir.mkdir(parents=True) From a3565ef60ebde175e1f120fd67775792ed52f77b Mon Sep 17 00:00:00 2001 From: a-simeshin <89605031+a-simeshin@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:31:53 +0300 Subject: [PATCH 19/48] Merge pull request #1415 from a-simeshin/feat/jdtls-path-override Allows setting paths for JDTLS and Lombok, thus allowing using Serena with JDTLS without forcing downloads --- CHANGELOG.md | 1 + docs/02-usage/050_configuration.md | 58 ++- .../language_servers/eclipse_jdtls.py | 381 ++++++++++++++- .../java/test_jdtls_path_resolution.py | 443 ++++++++++++++++++ 4 files changed, 853 insertions(+), 30 deletions(-) create mode 100644 test/solidlsp/java/test_jdtls_path_resolution.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6747ece8..5eadd611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 + - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 * Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 37fb55c0..d0b4c342 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -504,28 +504,72 @@ ls_specific_settings: #### Java (`eclipse.jdt.ls`) +Java support has two installation modes: + +1. **Default vscode-java VSIX mode** (no extra config required): Serena downloads the platform-specific + vscode-java VSIX (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution and + IntelliCode VSIX from public hosts on first use. +2. **Upstream JDTLS mode** (offline-friendly): Activated by setting both `jdtls_path` and `lombok_path`. + Uses an existing JDTLS installation (~100 MB) and the system JDK 21+. Nothing is downloaded. + Recommended for restricted-network/corporate environments. + +**When to use which mode:** + +- **Default vscode-java VSIX mode** — recommended for most users. No setup required; + Serena downloads everything on first use. +- **Upstream JDTLS mode** — recommended when: + - you cannot reach `github.com`, `services.gradle.org` or `marketplace.visualstudio.com` + from the host (corporate proxy, air-gapped network); + - you want a smaller on-disk footprint (~100 MB vs ~500 MB); + - you already maintain a JDTLS installation (e.g. for `nvim-jdtls` or another editor); + - your security policy prohibits per-project runtime downloads. + +**JDK 21+ is required** in upstream mode. Serena resolves the JDK in this order: +`ls_specific_settings.java.java_home` → `JAVA_HOME` env var → first `java` on `PATH`. +The resolved JVM is interrogated and rejected if its `java.specification.version` is below 21. + The following settings are supported for the Java language server: | Setting | Default | Description | |---|---|---| +| `jdtls_path` | `null` | Activates upstream JDTLS mode. Path to upstream JDTLS root (containing `plugins/` and `config_/`). Get via `brew install jdtls` or extract `jdt-language-server-*.tar.gz` from . Must be set together with `lombok_path`. | +| `lombok_path` | `null` | Path to the Lombok jar. Activates upstream JDTLS mode together with `jdtls_path`. Get from `~/.m2/repository/org/projectlombok/lombok//lombok-.jar` or download from . | +| `java_home` | `null` | (upstream-jdtls mode only) Path to JDK 21+ home directory used to launch JDTLS. Falls back to `JAVA_HOME` env var, then `which java`. | | `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_version` | `8.14.2` | Override the Gradle distribution version Serena downloads by default. | -| `vscode_java_version` | `1.42.0-561` | Override the bundled `vscode-java` runtime bundle version Serena downloads by default. | -| `intellicode_version` | `1.2.30` | Override the IntelliCode VSIX version Serena downloads by default. | +| `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. | +| `vscode_java_version` | `1.42.0-561` | (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. | | `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. | -| `intellicode_xms` | `100m` | Initial heap size for the IntelliCode embedded JVM. | +| `intellicode_xmx` | `1G` | (vscode-java mode only) Maximum heap size for the IntelliCode embedded JVM. | +| `intellicode_xms` | `100m` | (vscode-java mode only) Initial heap size for the IntelliCode embedded JVM. | -Note: +Notes: - When overriding `vscode_java_version`, Serena still assumes that the downloaded runtime bundle keeps the same internal directory layout and file names as the bundled default version. +- In upstream-jdtls mode, IntelliCode is not loaded (it's an ML completions ranker that is irrelevant to Serena's + symbol-tools workflow), and Serena does not ship a Gradle distribution. Maven projects work via JDTLS's bundled m2e. + Gradle projects must have `./gradlew` in the project, or rely on a system-installed Gradle through Buildship's + default discovery rules. +- 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. -Example for a project with custom Gradle plugins and JDK requirements: +Example: upstream-jdtls mode (offline / corporate network): + +```yaml +ls_specific_settings: + java: + jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec" + lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar" + # java_home: "/opt/homebrew/opt/openjdk@21" # optional +``` + +Example: default vscode-java VSIX mode for a project with custom Gradle plugins: ```yaml ls_specific_settings: diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index d90afd6d..eb6fbf02 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -7,9 +7,12 @@ import hashlib import logging import os import pathlib +import platform +import re import shutil +import subprocess import threading -from pathlib import PurePath +from pathlib import Path, PurePath from time import sleep from typing import cast @@ -18,6 +21,7 @@ from overrides import override from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer 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 @@ -42,28 +46,69 @@ INTELLICODE_ALLOWED_HOSTS = ( ) INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" +# Mapping from Serena's platform identifiers to upstream JDTLS config_ directory names +JDTLS_CONFIG_DIR_BY_PLATFORM = { + "osx-arm64": "config_mac_arm", + "darwin-arm64": "config_mac_arm", + "osx-x64": "config_mac", + "linux-arm64": "config_linux_arm", + "linux-x64": "config_linux", + "win-x64": "config_win", +} + +# Minimum supported JDK version for running JDTLS itself +JDTLS_MIN_JDK_VERSION = 21 + @dataclasses.dataclass class RuntimeDependencyPaths: """ - Stores the paths to the runtime dependencies of EclipseJDTLS + Stores the paths to the runtime dependencies of EclipseJDTLS. + + In the default mode (vscode-java VSIX), all paths are populated. + In the upstream-jdtls mode (when ``jdtls_path`` and ``lombok_path`` are set), + fields that have no upstream equivalent (gradle distribution, IntelliCode bundle) + are set to None. """ - gradle_path: str - lombok_jar_path: str jre_path: str jre_home_path: str jdtls_launcher_jar_path: str jdtls_readonly_config_path: str - intellicode_jar_path: str - intellisense_members_path: str + lombok_jar_path: str + gradle_path: str | None = None + intellicode_jar_path: str | None = None + intellisense_members_path: str | None = None class EclipseJDTLS(SolidLanguageServer): r""" The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class + Two installation modes are supported: + + 1. **Default vscode-java VSIX mode** (no extra config required) — Serena downloads the platform-specific + vscode-java VSIX bundle (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution + and IntelliCode VSIX from public hosts. Suitable when public network access is available. + + 2. **Upstream JDTLS mode** (activated by setting both ``jdtls_path`` and ``lombok_path``) — uses an + existing JDTLS installation and the system JDK. Nothing is downloaded. Suitable for restricted-network + environments. Requires JDK 21+ available via ``java_home`` setting / ``JAVA_HOME`` env / PATH. + Maven projects work out of the box (m2e bundled in JDTLS uses Maven Embedder); Gradle projects + need ``./gradlew`` in the project or a system-installed Gradle (Buildship default discovery). + You can configure the following options in ls_specific_settings (in serena_config.yml): + Upstream JDTLS mode (mutually exclusive group — set both to activate): + - jdtls_path: Path to upstream JDTLS root (containing plugins/ and config_/). + Get via 'brew install jdtls' or extract jdt-language-server-*.tar.gz from + https://download.eclipse.org/jdtls/snapshots/. + - lombok_path: Path to lombok jar (e.g. ~/.m2/repository/org/projectlombok/lombok//lombok-.jar + or download from https://projectlombok.org/downloads/). + + Optional in upstream-jdtls mode: + - java_home: Path to JDK 21+ home directory. Falls back to JAVA_HOME env, then 'which java'. + + General settings (apply in both modes): - 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) @@ -77,7 +122,16 @@ class EclipseJDTLS(SolidLanguageServer): - vscode_java_version: Override the pinned vscode-java runtime bundle version downloaded by Serena - intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena - Example configuration in ~/.serena/serena_config.yml: + Example configuration for upstream JDTLS mode (no downloads, suitable for offline/corporate): + ```yaml + ls_specific_settings: + java: + jdtls_path: "/opt/homebrew/Cellar/jdtls/1.50.0/libexec" # or extracted tar.gz path + lombok_path: "/Users/me/.m2/repository/org/projectlombok/lombok/1.18.36/lombok-1.18.36.jar" + # java_home: "/opt/homebrew/opt/openjdk@21" # optional, JAVA_HOME env / PATH used otherwise + ``` + + Example configuration for default vscode-java VSIX mode (auto-download): ```yaml ls_specific_settings: java: @@ -154,7 +208,29 @@ class EclipseJDTLS(SolidLanguageServer): ) -> RuntimeDependencyPaths: """ Setup runtime dependencies for EclipseJDTLS and return the paths. + + Two modes are supported: + + * **Upstream JDTLS mode** (activated when both ``jdtls_path`` and ``lombok_path`` are set + in ``ls_specific_settings.java``): uses an existing JDTLS installation (e.g. via + ``brew install jdtls`` or extracted ``jdt-language-server-*.tar.gz``) and the system JDK. + Nothing is downloaded by Serena. Suitable for restricted-network/corporate environments. + * **Default vscode-java VSIX mode** (activated otherwise): downloads the platform-specific + vscode-java VSIX bundle (containing JDTLS, bundled JRE 21, Lombok, IntelliCode), Gradle + distribution and IntelliCode VSIX from public hosts. Original behaviour, unchanged. """ + jdtls_path = custom_settings.get("jdtls_path") + lombok_path = custom_settings.get("lombok_path") + if jdtls_path or lombok_path: + # both must be set together to activate upstream mode + if not (jdtls_path and lombok_path): + raise SolidLSPException( + "Both 'jdtls_path' and 'lombok_path' must be set together in " + "ls_specific_settings.java to use the upstream JDTLS mode. " + "Set both, or remove both to use the default vscode-java VSIX mode." + ) + return EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_path), str(lombok_path), custom_settings) + platformId = PlatformUtils.get_platform_id() gradle_version = custom_settings.get("gradle_version", "8.14.2") vscode_java_version = custom_settings.get("vscode_java_version", "1.42.0-561") @@ -346,11 +422,255 @@ class EclipseJDTLS(SolidLanguageServer): intellisense_members_path=intellisense_members_path, ) + @staticmethod + def _setup_from_existing_install( + jdtls_path: str, lombok_path: str, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> RuntimeDependencyPaths: + """ + Builds RuntimeDependencyPaths from an already-installed upstream JDTLS distribution + and the system-installed JDK. No downloads are performed. + + :param jdtls_path: absolute path to the JDTLS root (containing ``plugins/`` and ``config_/``) + :param lombok_path: absolute path to the Lombok jar (mandatory; agent is always attached) + :param custom_settings: language-server-specific settings from ls_specific_settings.java + :return: populated RuntimeDependencyPaths with gradle/intellicode fields set to None + """ + # validate jdtls_path structure (root + plugins dir) + jdtls_root = Path(jdtls_path) + if not jdtls_root.is_dir(): + raise SolidLSPException( + f"Provided jdtls_path '{jdtls_path}' is not an existing directory.\n" + f"Fix: extract jdt-language-server-*.tar.gz from " + f"https://download.eclipse.org/jdtls/snapshots/ or run 'brew install jdtls', " + f"then set ls_specific_settings.java.jdtls_path to the extracted directory." + ) + plugins_dir = jdtls_root / "plugins" + if not plugins_dir.is_dir(): + raise SolidLSPException( + f"Invalid jdtls_path '{jdtls_path}': 'plugins/' directory not found.\n" + f"Expected upstream JDTLS layout (plugins/, config_/, features/) at the root. " + f"If you pointed at a vscode-java extension, use '/server' instead." + ) + + # resolve the main equinox launcher jar (excluding platform-specific native fragments) + launcher_jar = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins_dir) + + # resolve the platform-specific config directory under jdtls_root + config_dir = EclipseJDTLS.DependencyProvider._resolve_config_dir(jdtls_root) + + # validate lombok jar exists + if not Path(lombok_path).is_file(): + raise SolidLSPException( + f"Provided lombok_path '{lombok_path}' does not exist or is not a file.\n" + f"Fix: download lombok jar from https://projectlombok.org/downloads/ or use the one " + f"from your local Maven cache (~/.m2/repository/org/projectlombok/lombok//lombok-.jar)." + ) + + # resolve system JDK (priority: java_home setting -> JAVA_HOME env -> which java), + # interrogate the JVM for its real java.home and validate version >= 21 + jre_home_path, jre_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + log.info( + f"Using upstream JDTLS at '{jdtls_path}' with system JDK '{jre_home_path}'. " + f"Launcher: {launcher_jar.name}; config: {config_dir.name}; lombok: {lombok_path}" + ) + + return RuntimeDependencyPaths( + jre_path=jre_path, + jre_home_path=jre_home_path, + jdtls_launcher_jar_path=str(launcher_jar), + jdtls_readonly_config_path=str(config_dir), + lombok_jar_path=lombok_path, + gradle_path=None, + intellicode_jar_path=None, + intellisense_members_path=None, + ) + + @staticmethod + def _resolve_launcher_jar(plugins_dir: Path) -> Path: + """ + Locates the main Equinox launcher jar in JDTLS's ``plugins/`` directory, excluding + platform-specific native fragments like ``org.eclipse.equinox.launcher.cocoa.macosx.*``. + + :return: path to the main launcher jar (e.g. ``org.eclipse.equinox.launcher_1.7.0.v....jar``) + """ + # main launcher matches: org.eclipse.equinox.launcher_...jar (single underscore, + # immediately followed by version digits — fragments have additional dotted segments). + pattern = re.compile(r"^org\.eclipse\.equinox\.launcher_\d.*\.jar$") + matches = sorted(p for p in plugins_dir.glob("org.eclipse.equinox.launcher_*.jar") if pattern.match(p.name)) + if not matches: + raise SolidLSPException( + f"No main Equinox launcher jar found in '{plugins_dir}'. " + f"Expected file like 'org.eclipse.equinox.launcher_.jar'. " + f"Verify the JDTLS extraction is complete and not corrupted." + ) + # if multiple versions are present (rare), pick the highest by name + return matches[-1] + + @staticmethod + def _resolve_config_dir(jdtls_root: Path) -> Path: + """ + Locates the platform-specific OSGi configuration directory inside the JDTLS root. + + :return: path to ``config_/`` directory matching the current OS/arch + """ + platform_id = PlatformUtils.get_platform_id().value + config_dir_name = JDTLS_CONFIG_DIR_BY_PLATFORM.get(platform_id) + if config_dir_name is None: + raise SolidLSPException( + f"Unsupported platform '{platform_id}' for upstream JDTLS mode. " + f"Supported platforms: {sorted(set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()))}." + ) + config_dir = jdtls_root / config_dir_name + if not config_dir.is_dir(): + raise SolidLSPException( + f"Config directory '{config_dir}' not found. " + f"This JDTLS distribution does not support platform '{platform_id}'. " + f"Verify you downloaded the correct tar.gz for your OS/architecture." + ) + return config_dir + + @staticmethod + def _resolve_system_jdk(custom_settings: SolidLSPSettings.CustomLSSettings) -> tuple[str, str]: + """ + Resolves the system-installed JDK home and ``java`` executable, validates the version. + + The ``java`` executable is located by priority: ``java_home`` setting -> + ``JAVA_HOME`` env var -> ``java`` in PATH. The actual JDK home directory and + major version are then discovered by querying the JVM itself via + ``java -XshowSettings:properties -version`` — this is the single source of truth + and works correctly even when the locator is a system stub (e.g. ``/usr/bin/java`` + on macOS, which delegates to ``/usr/libexec/java_home`` under the hood and does not + resolve to the real JDK home via simple path traversal). + + :return: (jdk_home_directory, java_executable_path) + """ + # locate a java executable to interrogate + java_exe_name = "java.exe" if platform.system() == "Windows" else "java" + java_exe: str | None = None + source: str + + if explicit_home := custom_settings.get("java_home"): + candidate = str(Path(explicit_home) / "bin" / java_exe_name) + if not os.path.exists(candidate): + raise SolidLSPException( + f"java_home='{explicit_home}' is invalid: '{candidate}' does not exist. " + f"Set ls_specific_settings.java.java_home to a JDK home that contains bin/{java_exe_name}." + ) + java_exe = candidate + source = f"java_home setting ({explicit_home})" + elif env_home := os.environ.get("JAVA_HOME"): + candidate = str(Path(env_home) / "bin" / java_exe_name) + if os.path.exists(candidate): + java_exe = candidate + source = f"JAVA_HOME env ({env_home})" + else: + log.warning(f"JAVA_HOME='{env_home}' invalid (no '{candidate}'), falling back to PATH.") + + if java_exe is None: + java_in_path = shutil.which("java") + if java_in_path is None: + raise SolidLSPException( + "Could not locate a Java installation for JDTLS. " + "Set ls_specific_settings.java.java_home, set JAVA_HOME environment variable, " + f"or ensure 'java' is on PATH. Required: JDK {JDTLS_MIN_JDK_VERSION}+." + ) + java_exe = java_in_path + source = f"PATH ({java_in_path})" + + # interrogate the JVM for its real java.home and version (single source of truth) + real_jdk_home, major_version = EclipseJDTLS.DependencyProvider._inspect_java(java_exe) + + # validate version + if major_version < JDTLS_MIN_JDK_VERSION: + raise SolidLSPException( + f"JDTLS requires JDK {JDTLS_MIN_JDK_VERSION}+ but '{java_exe}' is JDK {major_version} " + f"(located via {source}, java.home={real_jdk_home}). " + f"Install a newer JDK and update ls_specific_settings.java.java_home or JAVA_HOME." + ) + log.info(f"Resolved JDK {major_version} via {source}; java.home={real_jdk_home}; java_exe={java_exe}.") + + # prefer to use bin/java from the *real* JDK home (so JDTLS subprocesses that read JAVA_HOME + # find a consistent layout); only fall back to the original locator if the real-home variant + # is missing for some reason. + real_java = str(Path(real_jdk_home) / "bin" / java_exe_name) + if os.path.exists(real_java): + java_exe = real_java + + return real_jdk_home, java_exe + + @staticmethod + def _inspect_java(java_exe: str) -> tuple[str, int]: + """ + Runs ``java -XshowSettings:properties -version`` and parses ``java.home`` and the + major version from the output. This is the most reliable cross-platform way to + discover the JDK home (works around macOS ``/usr/bin/java`` stub issue). + + :return: (java_home_directory_reported_by_jvm, major_version) + """ + try: + # both -XshowSettings:properties and -version write to stderr by convention + result = subprocess.run( + [java_exe, "-XshowSettings:properties", "-version"], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SolidLSPException(f"Failed to run '{java_exe} -XshowSettings:properties -version': {exc}") from exc + + output = (result.stderr or "") + "\n" + (result.stdout or "") + + # parse java.home from " java.home = /path/to/jdk" + home_match = re.search(r"java\.home\s*=\s*(.+)", output) + if not home_match: + raise SolidLSPException( + f"Could not parse java.home from '{java_exe}' output. " + f"This usually means '{java_exe}' is not a valid JDK installation. " + f"First lines of output: {output.strip().splitlines()[:5]}" + ) + real_home = home_match.group(1).strip() + + # parse major version from version line: 'openjdk version "21.0.2"' or 'java version "21.0.2"' + version_match = re.search(r'version "(\d+)(?:\.\d+)*"', output) + if not version_match: + raise SolidLSPException( + f"Could not parse Java version from '{java_exe}' output. " + f"Required: JDK {JDTLS_MIN_JDK_VERSION}+. " + f"First lines of output: {output.strip().splitlines()[:5]}" + ) + major = int(version_match.group(1)) + return real_home, major + + @staticmethod + def _compute_workspace_hash( + repository_root_path: str, + jdtls_launcher_jar_path: str, + custom_settings: SolidLSPSettings.CustomLSSettings, + ) -> str: + """ + Compute the JDTLS workspace directory name. + + Default mode hashes the project path only — preserves backwards compatibility with + workspaces created before upstream-JDTLS support. Upstream mode (when ``jdtls_path`` + is set) mixes in the launcher path so switching between default and upstream + installations (or between different upstream JDTLS versions) lands in a separate + ws_dir and avoids stale OSGi configs blocking startup. + """ + if custom_settings.get("jdtls_path"): + ws_hash_input = (repository_root_path + "|" + jdtls_launcher_jar_path).encode() + else: + ws_hash_input = repository_root_path.encode() + return hashlib.md5(ws_hash_input).hexdigest() + def create_launch_command(self) -> list[str]: # ws_dir is the workspace directory for the EclipseJDTLS server. - # Use a deterministic hash of the project path so the workspace - # (and its cached index) can be reused across restarts. - project_hash = hashlib.md5(self._repository_root_path.encode()).hexdigest() + project_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self._repository_root_path, + self.runtime_dependency_paths.jdtls_launcher_jar_path, + self._custom_settings, + ) ws_dir = str( PurePath( self._solidlsp_settings.ls_resources_dir, @@ -820,8 +1140,15 @@ class EclipseJDTLS(SolidLanguageServer): } initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] # type: ignore - bundles = [self.runtime_dependency_paths.intellicode_jar_path] - initialize_params["initializationOptions"]["bundles"] = bundles # type: ignore + + # 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 + else: + initialize_params["initializationOptions"]["bundles"] = [] # type: ignore + initialize_params["initializationOptions"]["settings"]["java"]["configuration"]["runtimes"] = [ # type: ignore {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} ] @@ -832,7 +1159,10 @@ class EclipseJDTLS(SolidLanguageServer): 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["home"] = self.runtime_dependency_paths.gradle_path + # 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: + 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) @@ -899,17 +1229,22 @@ class EclipseJDTLS(SolidLanguageServer): self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore - self._intellicode_enable_command_available.wait() + # IntelliCode enablement is only relevant in the default vscode-java VSIX mode where the + # IntelliCode bundle is shipped. In upstream-jdtls mode it's absent and the + # 'java.intellicode.enable' command will never be registered, so we skip the wait/call. + if self.runtime_dependency_paths.intellicode_jar_path is not None: + self._intellicode_enable_command_available.wait() - java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path - assert os.path.exists(java_intellisense_members_path) - intellicode_enable_result = self.server.send.execute_command( - { - "command": "java.intellicode.enable", - "arguments": [True, java_intellisense_members_path], - } - ) - assert intellicode_enable_result + java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path + assert java_intellisense_members_path is not None + assert os.path.exists(java_intellisense_members_path) + intellicode_enable_result = self.server.send.execute_command( + { + "command": "java.intellicode.enable", + "arguments": [True, java_intellisense_members_path], + } + ) + assert intellicode_enable_result if not self._service_ready_event.is_set(): log.info("Waiting for service to be ready ...") diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py new file mode 100644 index 00000000..2ce735d0 --- /dev/null +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -0,0 +1,443 @@ +""" +Unit tests for the offline (upstream) JDTLS resolution helpers in +``solidlsp.language_servers.eclipse_jdtls``. + +These tests cover only the path/version/validation logic; they do not start +JDTLS and do not require Java to be installed. Subprocess interactions are +mocked. +""" + +from __future__ import annotations + +import platform +from pathlib import Path +from unittest.mock import patch + +import pytest + +_JAVA_EXE_NAME = "java.exe" if platform.system() == "Windows" else "java" + +from solidlsp.language_servers.eclipse_jdtls import ( + JDTLS_CONFIG_DIR_BY_PLATFORM, + JDTLS_MIN_JDK_VERSION, + EclipseJDTLS, +) +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.settings import SolidLSPSettings + + +@pytest.fixture +def custom_settings() -> SolidLSPSettings.CustomLSSettings: + """Empty CustomLSSettings instance for tests that don't need any keys set.""" + return SolidLSPSettings.CustomLSSettings({}) + + +def _make_fake_jdtls_install( + root: Path, *, with_launcher: bool = True, with_native_fragments: bool = True, with_configs: bool = True +) -> Path: + """ + Builds a minimal fake upstream JDTLS layout under ``root``: ``plugins/`` with + a main equinox launcher jar (and optionally native fragments) and + ``config_/`` directories. Returns ``root``. + """ + plugins = root / "plugins" + plugins.mkdir(parents=True, exist_ok=True) + if with_launcher: + (plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar").touch() + if with_native_fragments: + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch() + if with_configs: + for config_name in set(JDTLS_CONFIG_DIR_BY_PLATFORM.values()): + (root / config_name).mkdir(exist_ok=True) + return root + + +# ---------------------------------------------------------------------------- +# _resolve_launcher_jar +# ---------------------------------------------------------------------------- + + +class TestResolveLauncherJar: + def test_picks_main_launcher_excluding_native_fragments(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + main_jar = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar" + main_jar.touch() + # native fragments should NOT be picked + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.gtk.linux.x86_64_1.2.0.v20240329-1112.jar").touch() + (plugins / "org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.0.v20240329-1112.jar").touch() + + result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + assert result == main_jar + + def test_picks_highest_version_when_multiple_main_launchers(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + (plugins / "org.eclipse.equinox.launcher_1.6.500.v20240916-1115.jar").touch() + newer = plugins / "org.eclipse.equinox.launcher_1.7.100.v20251111-0406.jar" + newer.touch() + (plugins / "org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar").touch() + + result = EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + assert result == newer, "Expected the lexicographically highest launcher version to be selected" + + def test_raises_when_no_launcher_present(self, tmp_path: Path) -> None: + plugins = tmp_path / "plugins" + plugins.mkdir() + # only native fragments, no main launcher + (plugins / "org.eclipse.equinox.launcher.cocoa.macosx.aarch64_1.2.0.v20240329-1112.jar").touch() + + with pytest.raises(SolidLSPException, match="No main Equinox launcher jar found"): + EclipseJDTLS.DependencyProvider._resolve_launcher_jar(plugins) + + +# ---------------------------------------------------------------------------- +# _resolve_config_dir +# ---------------------------------------------------------------------------- + + +class TestResolveConfigDir: + @pytest.mark.parametrize( + "platform_id,expected_dir", + [ + ("osx-arm64", "config_mac_arm"), + ("darwin-arm64", "config_mac_arm"), + ("osx-x64", "config_mac"), + ("linux-arm64", "config_linux_arm"), + ("linux-x64", "config_linux"), + ("win-x64", "config_win"), + ], + ) + def test_maps_platform_to_correct_config_dir(self, tmp_path: Path, platform_id: str, expected_dir: str) -> None: + _make_fake_jdtls_install(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = platform_id + result = EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + assert result.name == expected_dir + assert result.is_dir() + + def test_raises_when_config_dir_missing(self, tmp_path: Path) -> None: + # plugins/ exists but no config_/ for current OS + (tmp_path / "plugins").mkdir() + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = "linux-x64" + with pytest.raises(SolidLSPException, match="Config directory .* not found"): + EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + + def test_raises_for_unsupported_platform(self, tmp_path: Path) -> None: + _make_fake_jdtls_install(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_get_pid: + mock_get_pid.return_value.value = "freebsd-riscv64" + with pytest.raises(SolidLSPException, match="Unsupported platform"): + EclipseJDTLS.DependencyProvider._resolve_config_dir(tmp_path) + + +# ---------------------------------------------------------------------------- +# _inspect_java +# ---------------------------------------------------------------------------- + + +class TestInspectJava: + @staticmethod + def _fake_subprocess_result(stderr: str, stdout: str = "", returncode: int = 0): + """Build a minimal CompletedProcess-like object.""" + + class _Result: + def __init__(self) -> None: + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + return _Result() + + def test_parses_temurin_21_output(self) -> None: + # Real Temurin 21.0.10 output (java -XshowSettings:properties -version writes to stderr) + stderr = ( + "Property settings:\n" + " java.home = /Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home\n" + " java.version = 21.0.10\n" + 'openjdk version "21.0.10" 2026-01-20 LTS\n' + "OpenJDK Runtime Environment Temurin-21.0.10+7 (build 21.0.10+7-LTS)\n" + ) + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java") + assert home == "/Users/me/Library/Java/JavaVirtualMachines/temurin-21.0.10/Contents/Home" + assert major == 21 + + def test_parses_openjdk_17_output(self) -> None: + stderr = 'Property settings:\n java.home = /usr/lib/jvm/java-17-openjdk-amd64\nopenjdk version "17.0.5" 2023-10-17\n' + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + home, major = EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/java") + assert home == "/usr/lib/jvm/java-17-openjdk-amd64" + assert major == 17 + + def test_raises_when_java_home_property_missing(self) -> None: + stderr = 'java version "21.0.0"\n' + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + with pytest.raises(SolidLSPException, match="Could not parse java.home"): + EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava") + + def test_raises_when_version_string_missing(self) -> None: + stderr = " java.home = /opt/jdk\n" + with patch("subprocess.run", return_value=self._fake_subprocess_result(stderr)): + with pytest.raises(SolidLSPException, match="Could not parse Java version"): + EclipseJDTLS.DependencyProvider._inspect_java("/usr/bin/fakejava") + + def test_raises_on_subprocess_error(self) -> None: + with patch("subprocess.run", side_effect=OSError("permission denied")): + with pytest.raises(SolidLSPException, match="Failed to run"): + EclipseJDTLS.DependencyProvider._inspect_java("/nonexistent/java") + + +# ---------------------------------------------------------------------------- +# _resolve_system_jdk +# ---------------------------------------------------------------------------- + + +class TestResolveSystemJdk: + """Verifies the priority chain: java_home setting > JAVA_HOME env > PATH.""" + + @staticmethod + def _make_jdk_layout(root: Path, java_exe_name: str = _JAVA_EXE_NAME) -> Path: + bin_dir = root / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / java_exe_name).touch() + return root + + def _patch_inspect_java(self, real_home: str, major: int): + return patch.object(EclipseJDTLS.DependencyProvider, "_inspect_java", return_value=(real_home, major)) + + def test_uses_explicit_java_home_setting(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + explicit_jdk = self._make_jdk_layout(tmp_path / "explicit-jdk") + monkeypatch.delenv("JAVA_HOME", raising=False) + + with self._patch_inspect_java(str(explicit_jdk), 21): + settings = SolidLSPSettings.CustomLSSettings({"java_home": str(explicit_jdk)}) + home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + assert Path(home) == explicit_jdk + assert Path(java_path).name == _JAVA_EXE_NAME + + def test_falls_back_to_java_home_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + env_jdk = self._make_jdk_layout(tmp_path / "env-jdk") + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + with self._patch_inspect_java(str(env_jdk), 21): + settings = SolidLSPSettings.CustomLSSettings({}) + home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + assert Path(home) == env_jdk + + def test_falls_back_to_which_java( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + path_jdk = self._make_jdk_layout(tmp_path / "path-jdk") + java_path = path_jdk / "bin" / _JAVA_EXE_NAME + monkeypatch.delenv("JAVA_HOME", raising=False) + + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(java_path)): + with self._patch_inspect_java(str(path_jdk), 21): + home, _ = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + assert Path(home) == path_jdk + + def test_raises_when_no_java_anywhere( + self, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + monkeypatch.delenv("JAVA_HOME", raising=False) + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=None): + with pytest.raises(SolidLSPException, match="Could not locate a Java installation"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + def test_raises_for_invalid_explicit_java_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # path exists but no bin/java + broken = tmp_path / "broken-jdk" + broken.mkdir() + monkeypatch.delenv("JAVA_HOME", raising=False) + + settings = SolidLSPSettings.CustomLSSettings({"java_home": str(broken)}) + with pytest.raises(SolidLSPException, match=r"java_home=.*invalid"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + def test_raises_for_too_old_jdk(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + old_jdk = self._make_jdk_layout(tmp_path / "jdk-17") + monkeypatch.setenv("JAVA_HOME", str(old_jdk)) + + with self._patch_inspect_java(str(old_jdk), 17): + settings = SolidLSPSettings.CustomLSSettings({}) + with pytest.raises(SolidLSPException, match=f"requires JDK {JDTLS_MIN_JDK_VERSION}"): + EclipseJDTLS.DependencyProvider._resolve_system_jdk(settings) + + def test_uses_real_jdk_home_when_locator_is_macos_stub( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + """ + Simulates the macOS /usr/bin/java stub: the locator points to /usr/bin/java but the JVM's + java.home is the actual JDK. The resolver should report the real JDK home. + """ + real_jdk = self._make_jdk_layout(tmp_path / "real-jdk-21") + macos_stub = tmp_path / "fake-usr" / "bin" / _JAVA_EXE_NAME + macos_stub.parent.mkdir(parents=True) + macos_stub.touch() + monkeypatch.delenv("JAVA_HOME", raising=False) + + with patch("solidlsp.language_servers.eclipse_jdtls.shutil.which", return_value=str(macos_stub)): + with self._patch_inspect_java(str(real_jdk), 21): + home, java_path = EclipseJDTLS.DependencyProvider._resolve_system_jdk(custom_settings) + + # the resolver must trust the JVM's reported java.home, not parent.parent of the stub + assert Path(home) == real_jdk + # and prefer the real-home java executable over the stub + assert Path(java_path) == real_jdk / "bin" / _JAVA_EXE_NAME + + +# ---------------------------------------------------------------------------- +# _setup_from_existing_install +# ---------------------------------------------------------------------------- + + +class TestSetupFromExistingInstall: + @pytest.fixture + def lombok_jar(self, tmp_path: Path) -> Path: + jar = tmp_path / "lombok-1.18.44.jar" + jar.touch() + return jar + + @pytest.fixture + def jdtls_root(self, tmp_path: Path) -> Path: + return _make_fake_jdtls_install(tmp_path / "jdtls") + + def _fake_jdk(self, tmp_path: Path) -> Path: + jdk = tmp_path / "jdk-21" + (jdk / "bin").mkdir(parents=True) + (jdk / "bin" / "java").touch() + return jdk + + def test_happy_path_returns_runtime_paths_with_no_gradle_and_no_intellicode( + self, tmp_path: Path, jdtls_root: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + jdk = self._fake_jdk(tmp_path) + with patch("solidlsp.language_servers.eclipse_jdtls.PlatformUtils.get_platform_id") as mock_pid: + mock_pid.return_value.value = "linux-x64" + with patch.object(EclipseJDTLS.DependencyProvider, "_resolve_system_jdk", return_value=(str(jdk), str(jdk / "bin" / "java"))): + result = EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_root), str(lombok_jar), custom_settings) + + assert result.gradle_path is None + assert result.intellicode_jar_path is None + assert result.intellisense_members_path is None + assert result.lombok_jar_path == str(lombok_jar) + assert result.jre_home_path == str(jdk) + assert Path(result.jdtls_launcher_jar_path).name.startswith("org.eclipse.equinox.launcher_") + assert Path(result.jdtls_readonly_config_path).name == "config_linux" + + def test_raises_for_nonexistent_jdtls_path( + self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + with pytest.raises(SolidLSPException, match="not an existing directory"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(tmp_path / "does-not-exist"), str(lombok_jar), custom_settings) + + def test_raises_when_plugins_dir_missing( + self, tmp_path: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + empty_root = tmp_path / "no-plugins" + empty_root.mkdir() + with pytest.raises(SolidLSPException, match="'plugins/' directory not found"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(empty_root), str(lombok_jar), custom_settings) + + def test_raises_when_lombok_jar_missing( + self, jdtls_root: Path, tmp_path: Path, custom_settings: SolidLSPSettings.CustomLSSettings + ) -> None: + with pytest.raises(SolidLSPException, match="lombok_path .* does not exist"): + EclipseJDTLS.DependencyProvider._setup_from_existing_install( + str(jdtls_root), str(tmp_path / "no-such-lombok.jar"), custom_settings + ) + + +# ---------------------------------------------------------------------------- +# _setup_runtime_dependencies (mode-switch logic) +# ---------------------------------------------------------------------------- + + +class TestSetupRuntimeDependenciesModeSwitch: + """Verifies the activation trigger: both jdtls_path and lombok_path => upstream mode.""" + + def test_both_set_invokes_upstream_mode(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x", "lombok_path": "/y"}) + with patch.object(EclipseJDTLS.DependencyProvider, "_setup_from_existing_install", return_value="upstream-result") as mock_upstream: + result = EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + mock_upstream.assert_called_once_with("/x", "/y", settings) + assert result == "upstream-result" + + def test_only_jdtls_path_set_raises(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/x"}) + with pytest.raises(SolidLSPException, match="must be set together"): + EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + + def test_only_lombok_path_set_raises(self) -> None: + settings = SolidLSPSettings.CustomLSSettings({"lombok_path": "/y"}) + with pytest.raises(SolidLSPException, match="must be set together"): + EclipseJDTLS.DependencyProvider._setup_runtime_dependencies("/ignored", settings) + + +# ---------------------------------------------------------------------------- +# _compute_workspace_hash +# ---------------------------------------------------------------------------- + + +class TestComputeWorkspaceHash: + """ + Backwards-compatibility contract: users on the default route (no ``jdtls_path``) + must receive the *exact* same hash format that existed before upstream-JDTLS support + (i.e. ``md5(repository_root_path)``), so existing JDTLS workspaces and project caches + are reused without a one-time reindex after upgrading Serena. Upstream mode mixes the + launcher path into the hash to isolate it from the default workspace. + """ + + REPO = "/home/me/projects/widgets" + DEFAULT_LAUNCHER = "/srv/serena/static/eclipse-jdtls-1.49.0/plugins/org.eclipse.equinox.launcher_1.7.100.jar" + UPSTREAM_LAUNCHER = "/opt/homebrew/Cellar/jdtls/1.50.0/libexec/plugins/org.eclipse.equinox.launcher_1.7.0.jar" + + def test_default_mode_matches_pre_upstream_format(self) -> None: + """The hash MUST equal md5(repository_root_path) — the format produced by PR #1214.""" + import hashlib + + expected = hashlib.md5(self.REPO.encode()).hexdigest() + result = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, self.DEFAULT_LAUNCHER, SolidLSPSettings.CustomLSSettings({}) + ) + assert result == expected + + def test_default_mode_ignores_launcher_path(self) -> None: + """Default-mode hash must not depend on the launcher jar path (so default users keep cache).""" + empty_settings = SolidLSPSettings.CustomLSSettings({}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, empty_settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, empty_settings) + assert h1 == h2 + + def test_upstream_mode_includes_launcher_path(self) -> None: + """When jdtls_path is set, different launcher paths must produce different hashes.""" + settings = SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}) + h1 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings) + h2 = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.UPSTREAM_LAUNCHER, settings) + assert h1 != h2 + + def test_upstream_and_default_produce_different_hashes(self) -> None: + """Same repo + same launcher path but different mode → different ws_dir (isolation).""" + default_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, self.UPSTREAM_LAUNCHER, SolidLSPSettings.CustomLSSettings({}) + ) + upstream_h = EclipseJDTLS.DependencyProvider._compute_workspace_hash( + self.REPO, + self.UPSTREAM_LAUNCHER, + SolidLSPSettings.CustomLSSettings({"jdtls_path": "/opt/homebrew/Cellar/jdtls/1.50.0/libexec"}), + ) + assert default_h != upstream_h + + def test_different_repo_paths_produce_different_hashes(self) -> None: + empty_settings = SolidLSPSettings.CustomLSSettings({}) + 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 From 5dd1a410000142ce0d9f1acbbbe9dd9dd9d2fb13 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 28 Apr 2026 09:33:13 +0200 Subject: [PATCH 20/48] Changelog [ci skip] --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eadd611..96330eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* Language Servers: + - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 + + # v1.2.0 (2026-04-27) * General: @@ -57,7 +61,6 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356 - Add JSON language server support via `vscode-json-languageserver` (experimental) #1391 - Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397 - - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 * Dashboard: - Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`): From df0f476614a0c7a1242123956f2766dd771e26df Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 25 Mar 2026 11:03:14 +0100 Subject: [PATCH 21/48] LS: mark LS that support implementation request --- src/solidlsp/language_servers/csharp_language_server.py | 4 ++++ src/solidlsp/language_servers/eclipse_jdtls.py | 4 ++++ src/solidlsp/language_servers/gopls.py | 4 ++++ src/solidlsp/language_servers/rust_analyzer.py | 4 ++++ src/solidlsp/language_servers/typescript_language_server.py | 4 ++++ src/solidlsp/ls_config.py | 6 ++++++ 6 files changed, 26 insertions(+) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 59b1fc9e..51bed54e 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -206,6 +206,10 @@ class CSharpLanguageServer(SolidLanguageServer): scripts. If you have a custom .NET installation, ensure 'dotnet' is available in PATH with version 10 or higher. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly. diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index eb6fbf02..50deb1b0 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -152,6 +152,10 @@ class EclipseJDTLS(SolidLanguageServer): ``` """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a new EclipseJDTLS instance initializing the language server settings appropriately. diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index c855b836..3c098178 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -23,6 +23,10 @@ class Gopls(SolidLanguageServer): Provides Go specific instantiation of the LanguageServer class using gopls. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + @override def is_ignored_dirname(self, dirname: str) -> bool: # For Go projects, we should ignore: diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index f0ff00e6..2e222b5a 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -26,6 +26,10 @@ class RustAnalyzer(SolidLanguageServer): Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust. """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + @staticmethod def _determine_log_level(line: str) -> int: """Classify rust-analyzer stderr output to avoid false-positive errors.""" diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 5b7ec4fe..32e9ac22 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -67,6 +67,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") """ + @classmethod + def supports_implementation_request(cls) -> bool: + return True + # 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 diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index eb527dc3..1eb1295a 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -207,6 +207,12 @@ class Language(str, Enum): case _: return 2 + def supports_implementation_request(self) -> bool: + """ + Return whether the default language server for this language supports ``textDocument/implementation``. + """ + return self.get_ls_class().supports_implementation_request() + def get_source_fn_matcher(self) -> FilenameMatcher: match self: case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY: From 6dfda8c99ee12153d64d6769e1a73c085dfad15d Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 29 Apr 2026 12:14:12 +0200 Subject: [PATCH 22/48] Fix: Skip broken links in _iter_gitignore_files #1435 --- src/serena/util/file_system.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/serena/util/file_system.py b/src/serena/util/file_system.py index 8240653d..6ec1582e 100644 --- a/src/serena/util/file_system.py +++ b/src/serena/util/file_system.py @@ -173,6 +173,9 @@ class GitignoreParser: except PermissionError as ex: log.debug(f"Skipping entry due to permission error: {entry.path}", exc_info=ex) continue + except FileNotFoundError as ex: + log.debug(f"Skipping entry due to file not found error (possibly a broken link): {entry.path}", exc_info=ex) + continue while queue: next_abs_path = queue.pop(0) From c79e9d65f2a4531ddeee83ab53232b01ad713e79 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:33:02 +0200 Subject: [PATCH 23/48] Update header text in README.md [ci skip] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c819fb4..58c04aea 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

- Serena is the IDE for your coding agent. + The IDE for Your Coding Agent

From 68300e94ac076c725ec4b56b4ffe171cffabd58b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 16:23:10 +0200 Subject: [PATCH 24/48] RestartLanguageServerTool: Improve docstring --- src/serena/tools/symbol_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index a19a99b9..3881eb23 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -19,7 +19,7 @@ from solidlsp.ls_types import SymbolKind class RestartLanguageServerTool(Tool, ToolMarkerOptional): - """Restarts the language server, may be necessary when edits not through Serena happen.""" + """Restarts the language server(s).""" def apply(self) -> str: """Use this tool only on explicit user request or after confirmation. From 842d5b93748afbbb663298e3b97860d0cfd5b11b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 16:23:40 +0200 Subject: [PATCH 25/48] Mode jetbrains: Improve prompt, fixing typo [skip ci] --- src/serena/resources/config/internal_modes/jetbrains.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index 34baf6ce..47606f72 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -1,10 +1,11 @@ description: JetBrains tools replace language server-based tools prompt: | You have access to the very powerful JetBrains tools for symbolic operations. - These party replace regular tools you were informed about: + 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` excluded_tools: - find_symbol - find_referencing_symbols From 28c2866bf4970e5cee9f9dd9e1f07fc3e59d8f4a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 16:31:21 +0200 Subject: [PATCH 26/48] SolidLanguageServer: Improve docstrings [skip ci] --- src/solidlsp/ls.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6ae69a39..5f3d7d43 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -487,10 +487,14 @@ class SolidLanguageServer(ABC): :param config: the global SolidLSP configuration. :param repository_root_path: the root path of the repository. - :param process_launch_info: (DEPRECATED - implement _create_dependency_provider instead) + :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 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 @@ -729,10 +733,12 @@ class SolidLanguageServer(ABC): pass def _get_language_id_for_file(self, relative_file_path: str) -> str: - """Return the language ID for a file. + """ + Determines the language identifier to pass to the language server for the given file, + particularly `textDocument/didOpen` requests. - Override in subclasses to return file-specific language IDs. - Default implementation returns self.language_id. + 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 From 37c837ffd337d054bcada6879c94008781db5a0d Mon Sep 17 00:00:00 2001 From: Robin Jehn <71655429+RobinJehn@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:03:41 +0200 Subject: [PATCH 27/48] Send languageId 'typescriptreact' / 'javascriptreact' for .tsx / .jsx files (#1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: send languageId 'typescriptreact' / 'javascriptreact' for .tsx / .jsx Without this, .tsx files are opened with languageId 'typescript', so tsserver parses JSX as syntax errors and recovers by truncating the enclosing symbol's range at the first multi-line JSX expression. The resulting symptoms: - find_symbol(name, include_body=True) on a React component returns a body that ends mid-component, hiding everything below. - The same component shows up as multiple Method-kind symbols (one per call site) plus a separately-tracked Constant for the definition, because tsserver mistakes JSX-tag tokens for member access. Reproducer (before fix, real-world TSX file): SidebarPDF range = [11:1-25:207] (truncated at first JSX, line 25) After fix: SidebarPDF range = [11:1-281:2] (full body) The base SolidLanguageServer._get_language_id_for_file is explicitly designed for this — its docstring says "Override in subclasses to return file-specific language IDs." This change just exercises that hook for the React file extensions. Adds a regression test + jsx_component.tsx fixture covering the same arrow-function-with-multi-line-JSX-ternary pattern that triggers the truncation in real codebases (see oraios/serena#1029 for an earlier report whose root cause was not identified). --- .../typescript_language_server.py | 9 ++++ .../typescript/test_repo/jsx_component.tsx | 44 +++++++++++++++++++ .../repos/typescript/test_repo/tsconfig.json | 5 ++- .../typescript/test_typescript_basic.py | 31 +++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 test/resources/repos/typescript/test_repo/jsx_component.tsx diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 5b7ec4fe..ddf3f605 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -217,6 +217,15 @@ class TypeScriptLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] + def _get_language_id_for_file(self, relative_file_path: str) -> str: + # JSX is parsed as TS without this, which silently truncates symbol + # ranges at the first multi-line JSX expression. + if relative_file_path.endswith(".tsx"): + return "typescriptreact" + if relative_file_path.endswith(".jsx"): + 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. diff --git a/test/resources/repos/typescript/test_repo/jsx_component.tsx b/test/resources/repos/typescript/test_repo/jsx_component.tsx new file mode 100644 index 00000000..c8dea77d --- /dev/null +++ b/test/resources/repos/typescript/test_repo/jsx_component.tsx @@ -0,0 +1,44 @@ +// Regression fixture: mirrors the real-world pattern that caused +// languageId=typescript (instead of typescriptreact) to truncate symbol +// ranges at the first multi-line JSX expression. + +import * as React from "react" + +type SectionProps = { title: string; emphasised?: boolean } + +function Section({ title, emphasised }: SectionProps) { + return emphasised ? ( +

{title}

+ ) : ( +

{title}

+ ) +} + +export function JsxComponent({ heading, items }: { heading: string; items: string[] }) { + const renderHeader = (title: string) => + title.length > 10 ? ( +
+ ) : ( +
+ ) + + // The truncation bug used to cut JsxComponent's range right around here, + // hiding everything below from find_symbol. Keep this comment so the + // regression test can assert the function body extends past it. + const renderItem = (item: string, idx: number) => ( +
  • + {item} +
  • + ) + + return ( +
    + {renderHeader(heading)} +
      {items.map(renderItem)}
    +
    + ) +} + +export function trailingHelper(): string { + return "this symbol is invisible to find_symbol when JsxComponent's range is truncated" +} diff --git a/test/resources/repos/typescript/test_repo/tsconfig.json b/test/resources/repos/typescript/test_repo/tsconfig.json index ba0514f5..7267a686 100644 --- a/test/resources/repos/typescript/test_repo/tsconfig.json +++ b/test/resources/repos/typescript/test_repo/tsconfig.json @@ -5,7 +5,8 @@ "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "jsx": "react" }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py index ba86ba04..2caf6b11 100644 --- a/test/solidlsp/typescript/test_typescript_basic.py +++ b/test/solidlsp/typescript/test_typescript_basic.py @@ -33,6 +33,37 @@ class TestTypescriptLanguageServer: "index.ts should reference helperFunction (tried all positions in selectionRange)" ) + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_tsx_symbol_range_not_truncated_by_jsx(self, language_server: SolidLanguageServer) -> None: + # Regression: when the language id is sent as "typescript" instead of + # "typescriptreact" for .tsx files, tsserver parses JSX as syntax + # errors and recovers by truncating the enclosing symbol's range at + # the first multi-line JSX expression. find_symbol then returns a + # body that ends mid-component and hides everything below. + file_path = "jsx_component.tsx" + roots = language_server.request_document_symbols(file_path).root_symbols + + jsx_component = next((s for s in roots if s.get("name") == "JsxComponent"), None) + assert jsx_component is not None, "JsxComponent not found at root level of jsx_component.tsx" + + end_line = jsx_component["location"]["range"]["end"]["line"] + # JsxComponent's body extends to line 38 (0-based 37) in the fixture; + # the truncation bug cut it at the first multi-line JSX (~line 21). + # Use a generous lower bound so the test survives small fixture edits + # that don't affect the regression behaviour we care about. + assert end_line >= 30, ( + f"JsxComponent symbol range truncated at line {end_line + 1} (1-based); " + f"expected end at or past line 31 (1-based). " + f"This indicates the .tsx file was opened with the wrong languageId." + ) + + # The trailing helper must be visible as a top-level symbol — it lives + # past the truncation point and disappears entirely when the bug is + # active because tsserver stops emitting symbols after the parse error. + assert any(s.get("name") == "trailingHelper" for s in roots), ( + "trailingHelper missing from jsx_component.tsx root symbols; tsserver likely stopped parsing at the first JSX expression." + ) + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) From dfe1c3d7d16e8a301b2d3df6564b9621014db86b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 17:46:50 +0200 Subject: [PATCH 28/48] Revamp mode selection * Breaking change: Projects (project.yml) can no longer override `base_modes`. Instead, they can define `added_modes` to add modes on top of base and default modes. This allows `base_modes` to be fully applied for the computation of the base toolset as they are no longer dynamic. * Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`. --- CHANGELOG.md | 6 +++ docs/02-usage/050_configuration.md | 36 ++++++-------- src/serena/agent.py | 49 +++++++++++++------ src/serena/cli.py | 36 ++++++++++---- src/serena/config/serena_config.py | 26 +++++++--- src/serena/mcp.py | 9 ++-- src/serena/resources/project.template.yml | 22 ++++----- .../resources/serena_config.template.yml | 22 +++++---- 8 files changed, 129 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96330eaa..158a82b9 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. +* General: + - Breaking change in mode definitions: Projects (project.yml) can no longer override `base_modes`. + Instead, they can define `added_modes` to add modes on top of base and default modes. + See updated [documentation on modes](https://oraios.github.io/serena/02-usage/050_configuration.html#modes). + - Serena's default configuration now uses `interactive` and `editing` as `base_modes` instead of as `default_modes`. + * Language Servers: - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index d0b4c342..6efedcfc 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -118,32 +118,26 @@ Examples of built-in modes include: Find the concrete definitions of these modes [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/modes). -Active modes are configured in (from lowest to highest precedence): +The modes to be activated are configured in: * the global configuration file (`serena_config.yml`) + - defines `base_modes`, which are always included + - defines `default_modes`, which can be overridden by projects or command line parameters * the project configuration file (`project.yml`) + - defines `default_modes` (overriding the default modes in the global configuration) + - defines `added_modes`, which are added on top * at startup via command-line parameters + - can override default modes with `--mode` + - can define modes to be added on top with `--add-mode` -The two former sources define both **base modes** and **default modes**. -Ultimately, the active modes are the union of base modes and default modes (after applying all overrides). -Command-line parameters override default modes but not base modes. -Base modes should thus be used to define modes that you always want to be active, regardless of command-line parameters. +Ultimately, the active modes are given by the union of + * `base_modes` defined in the global configuration (always active) + * `default_modes` (defined in the global configuration, optionally overridden by the project/CLI) + * `added_modes` (defined in the project configuration/via CLI parameters) -Command-line parameters for overriding default modes: -When launching the MCP sever, specify modes using `--mode `; multiple modes can be specified, e.g. `--mode planning --mode no-onboarding`. - -:::{important} -By default, Serena activates the two modes `interactive` and `editing` (as defined in the global configuration). - -As soon as you start to specify modes via the command line, only the modes you explicitly specify will be active, however. -Therefore, if you want to keep the default modes, you must specify them as well. -For example, to add mode `no-memories` to the default behaviour, specify -```shell ---mode interactive --mode editing --mode no-memories -``` - -If you want to keep certain modes as always active, regardless of command-line parameters, -define them as *base modes* in the global or project configuration. -::: +So you should + * define modes you definitely always want to use in `base_modes`, + * define modes that you typically want to use but sometimes want to override in `default_modes`, + * use `added_modes` to add modes that you need only for specific projects/sessions. :::{note} **Mode Compatibility**: While you can combine modes, some may be semantically incompatible (e.g., `interactive` and `one-shot`). diff --git a/src/serena/agent.py b/src/serena/agent.py index 2daf0b4e..db67577d 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -31,6 +31,8 @@ from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( LanguageBackend, ModeSelectionDefinition, + ModeSelectionDefinitionWithAddedModes, + ModeSelectionDefinitionWithBaseModes, NamedToolInclusionDefinition, RegisteredProject, SerenaConfig, @@ -206,18 +208,36 @@ class ActiveModes: def __init__(self) -> None: self._configured_base_modes: Sequence[str] | None = None self._configured_default_modes: Sequence[str] | None = None + self._added_modes: set[str] = set() + self._dynamically_activated_mode_names: set[str] = set() + """ + the subset of active mode names that are dynamically activated (not necessarily enabled after project change) + """ self._active_mode_names: Sequence[str] = [] + """ + the full list of active mode names + """ def apply(self, mode_selection: ModeSelectionDefinition) -> None: + log.debug("Applying mode selection definition %s", mode_selection) + # apply overrides - log.debug("Applying mode selection: default_modes=%s, base_modes=%s", mode_selection.default_modes, mode_selection.base_modes) - if mode_selection.base_modes is not None: - self._configured_base_modes = mode_selection.base_modes + if isinstance(mode_selection, ModeSelectionDefinitionWithBaseModes): + if mode_selection.base_modes is not None: + self._configured_base_modes = mode_selection.base_modes if mode_selection.default_modes is not None: self._configured_default_modes = mode_selection.default_modes log.debug("Current mode selection: base_modes=%s, default_modes=%s", self._configured_base_modes, self._configured_default_modes) - self._active_mode_names = sorted(set(self._configured_base_modes or []) | set(self._configured_default_modes or [])) + # apply added modes (if any) + if isinstance(mode_selection, ModeSelectionDefinitionWithAddedModes): + if mode_selection.added_modes: + log.debug("Adding modes: %s", mode_selection.added_modes) + self._added_modes.update(mode_selection.added_modes) + log.debug("Current added modes: %s", self._added_modes) + + self._dynamically_activated_mode_names = set(self._configured_default_modes or []) | self._added_modes + self._active_mode_names = sorted(set(self._configured_base_modes or []) | self._dynamically_activated_mode_names) def get_mode_names(self) -> Sequence[str]: return self._active_mode_names @@ -231,8 +251,8 @@ class ActiveModes: def get_modes(self) -> Sequence[SerenaAgentMode]: return [self.get_mode_instance(mode_name) for mode_name in self._active_mode_names] - def get_default_modes(self) -> Sequence[SerenaAgentMode]: - return [self.get_mode_instance(mode_name) for mode_name in self._configured_default_modes or []] + 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 []] @@ -511,8 +531,7 @@ class SerenaAgent: :param serena_config: the Serena configuration or None to read the configuration from the default location. :param context: the context in which the agent is operating, None for default context. The context may adjust prompts, tool availability, and tool descriptions. - :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. - The modes may adjust prompts, tool availability, and tool descriptions. + :param modes: mode selection definition to apply for this session :param memory_log_handler: a MemoryLogHandler instance from which to read log messages; if None, a new one will be created if necessary. """ @@ -521,7 +540,7 @@ class SerenaAgent: self._gui_log_viewer: Optional["GuiLogViewer"] = None self._dashboard_manager: DashboardManager | None = None self._project_prompt_status = ProjectPromptProvisionStatus() - self._mode_overrides = modes + self._session_mode_selection_definition = modes self.version = serena_version() # obtain serena configuration using the decoupled factory function @@ -712,9 +731,11 @@ class SerenaAgent: # * base modes: These cannot be changed, so they are fully applied for base_mode in modes.get_base_modes(): tool_inclusion_definitions.append(base_mode) - # * default modes: When not in a single-project context, these modes are dynamic (can later be turned off), - # so we consider only their inclusions (but not their exclusions, because these must not be hard) - for mode in modes.get_default_modes(): + # * dynamically activated modes: + # - When not in a single-project context, these modes can later be turned off, + # so we consider only their inclusions (but not their exclusions, because these must not be hard). + # - In a single-project context, we can consider them fully. + for mode in modes.get_dynamically_activated_modes(): if is_single_project: tool_inclusion_definitions.append(mode) else: @@ -986,8 +1007,8 @@ class SerenaAgent: self._active_modes.apply(self.serena_config) if self._active_project: self._active_modes.apply(self._active_project.project_config) - if self._mode_overrides: - self._active_modes.apply(self._mode_overrides) + if self._session_mode_selection_definition: + self._active_modes.apply(self._session_mode_selection_definition) if log_message: active_mode_names = self._active_modes.get_mode_names() log.info(f"Active modes ({len(active_mode_names)}): {', '.join(active_mode_names)}") diff --git a/src/serena/cli.py b/src/serena/cli.py index e7407545..8591d712 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -23,6 +23,7 @@ from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode from serena.config.serena_config import ( LanguageBackend, ModeSelectionDefinition, + ModeSelectionDefinitionWithAddedModes, ProjectConfig, RegisteredProject, SerenaConfig, @@ -37,7 +38,6 @@ from serena.constants import ( ) from serena.prompt_factory import SerenaPromptFactory from serena.util.cli_util import AutoRegisteringGroup -from serena.util.dataclass import get_dataclass_default from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind @@ -46,15 +46,17 @@ from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) _MAX_CONTENT_WIDTH = 200 -_MODES_EXPLANATION = f"""\b\nBuilt-in mode names or paths to custom mode YAMLs with which to -override the default modes defined in the global Serena configuration or +_MODES_EXPLANATION = """\b\nBuilt-in mode names or paths to custom mode YAMLs with which to +override the default_modes defined in the global Serena configuration or the active project. For details on mode configuration, see https://oraios.github.io/serena/02-usage/050_configuration.html#modes. -If no configuration changes were made, the base defaults are: - {get_dataclass_default(SerenaConfig, "default_modes")}. -Overriding them means that they no longer apply, so you will need to -re-specify them in addition to further modes if you want to keep them.""" +""" +_ADD_MODES_EXPLANATION = """\b\nMode names or paths to custom mode YAMLs which shall +be added on top of the other modes specified by the global/project configuration. +For details on mode configuration, see + https://oraios.github.io/serena/02-usage/050_configuration.html#modes. +""" def find_project_root(root: str | Path | None = None) -> str | None: @@ -228,13 +230,22 @@ class TopLevelCommands(AutoRegisteringGroup): ) @click.option( "--mode", - "modes", + "default_modes", type=str, multiple=True, default=(), show_default=False, help=_MODES_EXPLANATION, ) + @click.option( + "--add-mode", + "added_modes", + type=str, + multiple=True, + default=(), + show_default=False, + help=_ADD_MODES_EXPLANATION, + ) @click.option( "--language-backend", type=click.Choice([lb.value for lb in LanguageBackend]), @@ -300,7 +311,8 @@ class TopLevelCommands(AutoRegisteringGroup): project_file_arg: str | None, project_from_cwd: bool | None, context: str, - modes: Sequence[str], + default_modes: Sequence[str], + added_modes: Sequence[str], language_backend: str | None, transport: Literal["stdio", "sse", "streamable-http"], host: str, @@ -346,11 +358,15 @@ class TopLevelCommands(AutoRegisteringGroup): project_file = project_file_arg or project + mode_selection_def: ModeSelectionDefinition | None = None + if default_modes or added_modes: + mode_selection_def = ModeSelectionDefinitionWithAddedModes(default_modes=default_modes or None, added_modes=added_modes or None) + factory = SerenaMCPFactory(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( host=host, port=port, - modes=modes, + mode_selection_def=mode_selection_def, language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, enable_web_dashboard=enable_web_dashboard, open_web_dashboard=open_web_dashboard, diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 329e0e86..bade9a28 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -168,10 +168,22 @@ class NamedToolInclusionDefinition(ToolInclusionDefinition): @dataclass class ModeSelectionDefinition: - base_modes: Sequence[str] | None = None default_modes: Sequence[str] | None = None +@dataclass +class ModeSelectionDefinitionWithBaseModes(ModeSelectionDefinition): + base_modes: Sequence[str] | None = ("interactive", "editing") + """ + the base modes to use, which are always guaranteed to be included + """ + + +@dataclass +class ModeSelectionDefinitionWithAddedModes(ModeSelectionDefinition): + added_modes: Sequence[str] | None = None + + class LanguageBackend(Enum): LSP = "LSP" """ @@ -228,7 +240,7 @@ class LineEnding(Enum): @dataclass -class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin): +class SharedConfig(ToolInclusionDefinition, ToStringMixin): """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form (same as in ModeSelectionDefinition). The defaults here shall be none and should be set to the global default values in SerenaConfig. @@ -255,7 +267,7 @@ Uses $projectDir and $projectFolderName as placeholders. @dataclass(kw_only=True) -class ProjectConfig(SharedConfig): +class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name: str languages: list[Language] ignored_paths: list[str] = field(default_factory=list) @@ -471,6 +483,9 @@ class ProjectConfig(SharedConfig): excluded_tools = data["excluded_tools"] or [] included_optional_tools = data["included_optional_tools"] 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.") + return cls( project_name=data["project_name"], languages=languages, @@ -486,7 +501,7 @@ class ProjectConfig(SharedConfig): encoding=data["encoding"], line_ending=line_ending, language_backend=language_backend, - base_modes=data["base_modes"], + added_modes=data["added_modes"], default_modes=data["default_modes"], symbol_info_budget=symbol_info_budget, ls_specific_settings=data.get("ls_specific_settings", {}), @@ -677,7 +692,7 @@ class RegisteredProject(ToStringMixin): @dataclass(kw_only=True) -class SerenaConfig(SharedConfig): +class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): """ Holds the Serena agent configuration, which is typically loaded from a YAML configuration file (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed. @@ -731,7 +746,6 @@ class SerenaConfig(SharedConfig): """ the language backend to use for code understanding features """ - default_modes: Sequence[str] | None = ("interactive", "editing") line_ending: LineEnding = LineEnding.NATIVE symbol_info_budget: float = 10.0 """ diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 13b9a00c..130f432a 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -3,7 +3,7 @@ The Serena Model Context Protocol (MCP) Server """ import sys -from collections.abc import AsyncIterator, Iterator, Sequence +from collections.abc import AsyncIterator, Iterator from contextlib import asynccontextmanager from copy import deepcopy from dataclasses import dataclass @@ -272,7 +272,7 @@ class SerenaMCPFactory: self, host: str = "127.0.0.1", port: int = 8000, - modes: Sequence[str] = (), + mode_selection_def: ModeSelectionDefinition | None = None, language_backend: LanguageBackend | None = None, enable_web_dashboard: bool | None = None, enable_gui_log_window: bool | None = None, @@ -286,7 +286,7 @@ class SerenaMCPFactory: :param host: The host to bind to :param port: The port to bind to - :param modes: List of mode names or paths to mode files + :param mode_selection_def: the mode selection definition to apply :param language_backend: the language backend to use, overriding the configuration setting. :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. @@ -318,9 +318,6 @@ class SerenaMCPFactory: if language_backend is not None: config.language_backend = language_backend - mode_selection_def: ModeSelectionDefinition | None = None - if modes: - mode_selection_def = ModeSelectionDefinition(default_modes=modes) self.agent = self._create_serena_agent(config, mode_selection_def) except Exception as e: diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index ada6fd67..fb1ddbd7 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -115,21 +115,21 @@ included_optional_tools: [] # This cannot be combined with non-empty excluded_tools or included_optional_tools. fixed_tools: [] -# list of mode names to that are always to be included in the set of active modes -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this setting overrides the global configuration. -# Set this to [] to disable base modes for this project. -# Set this to a list of mode names to always include the respective modes for this project. -base_modes: - -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. # This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). initial_prompt: "" diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 18974358..40c668cb 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -112,19 +112,23 @@ included_optional_tools: [] # This cannot be combined with non-empty excluded_tools or included_optional_tools. fixed_tools: [] -# list of mode names to that are always to be included in the set of active modes -# The full set of modes to be activated is base_modes + default_modes. -# If this is undefined, no base modes are included. -# The project configuration (project.yml) may override this setting. +# list of mode names to that are always to be included in the set of active modes. +# The full set of modes to be activated is base_modes + default_modes + added_modes, +# where added_modes can be defined by projects/CLI parameters. +# If this is undefined/empty, no base modes are included. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes base_modes: - -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode). -default_modes: - interactive - editing +# list of mode names that are to be activated by default (but can be overridden by projects/CLI params). +# The full set of modes to be activated is base_modes + default_modes + added_modes, +# where added_modes are defined by projects/CLI parameters. +# If this is undefined/empty, no default modes are defined. +# These modes can be overridden by the project configuration (project.yml) or through the CLI (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + # Used as default for tools where the apply method has a default maximal answer length. # Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default # through the global configuration. From 1184a9b5bdba56ee27d4021e5221bce2a6100e87 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 17:52:27 +0200 Subject: [PATCH 29/48] Remove outdated list of tools, add link to tool list instead --- src/serena/resources/project.template.yml | 43 ++--------------------- 1 file changed, 3 insertions(+), 40 deletions(-) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index fb1ddbd7..b6ae1f2e 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -65,54 +65,17 @@ read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) -# -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project based on the project name or path. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly, -# for example by saying that the information retrieved from a memory file is no longer correct -# or no longer relevant for the project. -# * `edit_memory`: Replaces content matching a regular expression in a memory. -# * `execute_shell_command`: Executes a shell command. -# * `find_file`: Finds files in the given relative paths -# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend -# * `find_symbol`: Performs a global (or local) search using the language server backend. -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual') -# for clients that do not read the initial instructions when the MCP server is connected. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Read the content of a memory file. This tool should only be used if the information -# is relevant to the current task. You can infer whether the information -# is relevant from the memory file name. -# You should not read the same memory file multiple times in the same conversation. -# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported -# (e.g., renaming "global/foo" to "bar" moves it from global to project scope). -# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities. -# For JB, we use a separate tool. -# * `replace_content`: Replaces content in a file (optionally using regular expressions). -# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend. -# * `safe_delete_symbol`: -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. -# The memory name should be meaningful. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # list of mode names that are to be activated by default, overriding the setting in the global configuration. From b9996cd6630e2d18a98df8ab8e0b5cee20f9c89c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 17:53:57 +0200 Subject: [PATCH 30/48] Update language list (based on scripts/print_language_list.py) --- src/serena/resources/project.template.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index b6ae1f2e..cfa204ec 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -3,16 +3,18 @@ project_name: "project_name" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# haxe java julia kotlin lua -# markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# 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 # (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.) From 4991b025a5d9f70966bf8dc1f9ac92c4372cdd10 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 17:57:58 +0200 Subject: [PATCH 31/48] Update project.yml --- .serena/project.yml | 74 +++++++++++++++------------------------------ 1 file changed, 25 insertions(+), 49 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 84b6fcf8..c9fd4467 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,15 +3,18 @@ project_name: "serena" # list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig +# 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 # (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.) @@ -44,49 +47,12 @@ read_only: false # list of tool names to exclude. # This extends the existing exclusions (e.g. from the global configuration) -# -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# * `activate_project`: Activates a project based on the project name or path. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly, -# for example by saying that the information retrieved from a memory file is no longer correct -# or no longer relevant for the project. -# * `edit_memory`: Replaces content matching a regular expression in a memory. -# * `execute_shell_command`: Executes a shell command. -# * `find_file`: Finds files in the given relative paths -# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend -# * `find_symbol`: Performs a global (or local) search using the language server backend. -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual') -# for clients that do not read the initial instructions when the MCP server is connected. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Read the content of a memory file. This tool should only be used if the information -# is relevant to the current task. You can infer whether the information -# is relevant from the memory file name. -# You should not read the same memory file multiple times in the same conversation. -# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported -# (e.g., renaming "global/foo" to "bar" moves it from global to project scope). -# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities. -# For JB, we use a separate tool. -# * `replace_content`: Replaces content in a file (optionally using regular expressions). -# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend. -# * `safe_delete_symbol`: -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format. -# The memory name should be meaningful. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html excluded_tools: [] # list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). # This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html included_optional_tools: [] # initial prompt for the project. It will always be given to the LLM upon activating the project @@ -121,14 +87,17 @@ encoding: utf-8 base_modes: # list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. # If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. # Otherwise, this overrides the setting from the global configuration (serena_config.yml). # This setting can, in turn, be overridden by CLI parameters (--mode). +# Set this to [] to not use the default modes defined in the global config for this project. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes default_modes: # fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. # This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html fixed_tools: [] # time budget (seconds) per tool call for the retrieval of additional symbol information @@ -166,3 +135,10 @@ ignored_memory_patterns: [] # 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. ls_specific_settings: {} + +# list of mode names to be activated additionally for this project. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# Otherwise, this setting overrides the global configuration. +# Set this to a list of mode names to always include the respective modes for this project. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: From dc491db2d4342474ec8ff4a941f006f8ba9d04ec Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 19:12:34 +0200 Subject: [PATCH 32/48] Improve comment on web_dashboard_open_on_launch --- src/serena/resources/serena_config.template.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 40c668cb..93036b96 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -37,13 +37,15 @@ gui_log_window: False # Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard: True -# whether to open the Dashboard window/browser tab when Serena starts (provided that web_dashboard is enabled). -# If set to false, you can still open the dashboard manually by clicking on the Serena icon in your system -# tray on Windows and macOS. On Linux, there is no system tray support, so you can only open the dashboard by -# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by -# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port -# may be higher if you have multiple instances running; try ports 24283, 24284, etc.) -# See also: https://oraios.github.io/serena/02-usage/060_dashboard.html +# whether to open the Dashboard window/browser tab when Serena starts (provided that `web_dashboard` is enabled). +# If set to false, you can still open the dashboard manually: +# * When using an interface that supports a tray icon (see setting `web_dashboard_interface`), +# you can conveniently open the dashboard from the system tray. +# * When using the `browser` interface (no tray icon), so you can only open the dashboard by +# a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by +# b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port +# may be higher if you have multiple instances running; try ports 24283, 24284, etc.) +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_open_on_launch: True # defines the interface (application mode) used for the web dashboard (if enabled). @@ -59,6 +61,7 @@ web_dashboard_open_on_launch: True # opening the dashboard in browser tabs when selected from the tray menu. # This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested. # On Linux, this cannot be universally supported, but it may work in some desktop environments. +# See https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_interface: # the address the web dashboard will listen on (bind address). From c55e0c900e9b1249ad0bd682fe2dc7407a6e547b Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 25 Mar 2026 11:03:14 +0100 Subject: [PATCH 33/48] Diagnostics, implementation and definition tools (WIP) --- scripts/demo_diagnostics.py | 170 +++++ scripts/demo_find_defining_symbol.py | 128 ++++ scripts/demo_find_implementing_symbol.py | 76 +++ src/serena/code_editor.py | 53 ++ src/serena/symbol.py | 261 +++++++- src/serena/tools/file_tools.py | 55 +- src/serena/tools/symbol_tools.py | 471 +++++++++++++- src/serena/tools/tools_base.py | 200 +++++- src/solidlsp/ls.py | 275 +++++++- src/solidlsp/ls_process.py | 17 + src/solidlsp/ls_types.py | 2 +- test/conftest.py | 61 ++ test/diagnostics_cases.py | 230 +++++++ .../src/test_app/diagnostics_sample.clj | 9 + .../repos/cpp/test_repo/compile_commands.json | 7 +- .../cpp/test_repo/diagnostics_sample.cpp | 8 + .../csharp/test_repo/DiagnosticsSample.cs | 19 + .../repos/csharp/test_repo/Program.cs | 8 +- .../test_repo/Services/ConsoleGreeter.cs | 10 + .../csharp/test_repo/Services/IGreeter.cs | 7 + .../fsharp/test_repo/DiagnosticsSample.fs | 11 + .../repos/fsharp/test_repo/Formatter.fs | 8 + .../repos/fsharp/test_repo/Program.fs | 10 +- .../repos/fsharp/test_repo/Shapes.fs | 10 + .../repos/fsharp/test_repo/TestProject.fsproj | 5 +- .../repos/go/test_repo/diagnostics_sample.go | 11 + test/resources/repos/go/test_repo/main.go | 12 + .../main/java/test_repo/ConsoleGreeter.java | 8 + .../java/test_repo/DiagnosticsSample.java | 13 + .../src/main/java/test_repo/Greeter.java | 5 + .../src/main/java/test_repo/Main.java | 2 + .../gradle/wrapper/gradle-wrapper.properties | 7 + test/resources/repos/kotlin/test_repo/gradlew | 251 ++++++++ .../repos/kotlin/test_repo/gradlew.bat | 94 +++ .../kotlin/test_repo/DiagnosticsSample.kt | 11 + .../lean4/test_repo/DiagnosticsSample.lean | 6 + test/resources/repos/lua/test_repo/main.lua | 11 +- .../repos/lua/test_repo/src/animals.lua | 37 ++ .../php/test_repo/diagnostics_sample.php | 11 + .../test_repo/diagnostics_sample.ps1 | 9 + .../test_repo/test_repo/diagnostics_sample.py | 11 + test/resources/repos/ruby/test_repo/lib.rb | 12 + test/resources/repos/ruby/test_repo/main.rb | 2 + .../rust/test_repo/src/diagnostics_sample.rs | 9 + .../resources/repos/rust/test_repo/src/lib.rs | 14 + .../repos/rust/test_repo/src/main.rs | 4 +- .../typescript/test_repo/.serena/project.yml | 148 ++++- .../test_repo/diagnostics_sample.ts | 9 + .../repos/typescript/test_repo/formatters.ts | 9 + .../repos/typescript/test_repo/index.ts | 5 + .../__snapshots__/test_symbol_editing.ambr | 32 +- test/serena/test_serena_agent.py | 596 +++++++++++++++++- test/solidlsp/clojure/__init__.py | 4 +- test/solidlsp/csharp/test_csharp_basic.py | 28 + test/solidlsp/fortran/test_fortran_basic.py | 28 + test/solidlsp/fsharp/test_fsharp_basic.py | 29 +- test/solidlsp/go/test_go_basic.py | 28 + test/solidlsp/java/test_java_basic.py | 34 +- test/solidlsp/lua/test_lua_basic.py | 38 +- test/solidlsp/python/test_python_basic.py | 21 + test/solidlsp/rust/test_rust_basic.py | 28 + test/solidlsp/solidity/test_solidity_basic.py | 28 + test/solidlsp/test_defining_symbol_matrix.py | 139 ++++ test/solidlsp/test_diagnostics_matrix.py | 79 +++ test/solidlsp/test_ls_common.py | 4 +- .../typescript/test_typescript_basic.py | 28 + 66 files changed, 3928 insertions(+), 38 deletions(-) create mode 100644 scripts/demo_diagnostics.py create mode 100644 scripts/demo_find_defining_symbol.py create mode 100644 scripts/demo_find_implementing_symbol.py create mode 100644 test/diagnostics_cases.py create mode 100644 test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj create mode 100644 test/resources/repos/cpp/test_repo/diagnostics_sample.cpp create mode 100644 test/resources/repos/csharp/test_repo/DiagnosticsSample.cs create mode 100644 test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs create mode 100644 test/resources/repos/csharp/test_repo/Services/IGreeter.cs create mode 100644 test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs create mode 100644 test/resources/repos/fsharp/test_repo/Formatter.fs create mode 100644 test/resources/repos/fsharp/test_repo/Shapes.fs create mode 100644 test/resources/repos/go/test_repo/diagnostics_sample.go create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java create mode 100644 test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java create mode 100644 test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties create mode 100644 test/resources/repos/kotlin/test_repo/gradlew create mode 100644 test/resources/repos/kotlin/test_repo/gradlew.bat create mode 100644 test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt create mode 100644 test/resources/repos/lean4/test_repo/DiagnosticsSample.lean create mode 100644 test/resources/repos/lua/test_repo/src/animals.lua create mode 100644 test/resources/repos/php/test_repo/diagnostics_sample.php create mode 100644 test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 create mode 100644 test/resources/repos/python/test_repo/test_repo/diagnostics_sample.py create mode 100644 test/resources/repos/rust/test_repo/src/diagnostics_sample.rs create mode 100644 test/resources/repos/typescript/test_repo/diagnostics_sample.ts create mode 100644 test/resources/repos/typescript/test_repo/formatters.ts create mode 100644 test/solidlsp/test_defining_symbol_matrix.py create mode 100644 test/solidlsp/test_diagnostics_matrix.py diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py new file mode 100644 index 00000000..7aa85361 --- /dev/null +++ b/scripts/demo_diagnostics.py @@ -0,0 +1,170 @@ +""" +Demonstrates diagnostics tools and edit-tool diagnostic reporting on the Serena repo itself. + +The script creates a temporary Python file inside this repository, introduces one warning, +shows file and symbol diagnostics, then introduces another warning and verifies that the +second edit reports only the newly introduced warning. +""" + +import json +import shutil +import tempfile +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import CreateTextFileTool, GetDiagnosticsForFileTool, GetDiagnosticsForSymbolTool, ReplaceContentTool, Tool +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +REPO_PATH = Path(REPO_ROOT) +EDIT_RESULT_PREFIX = "Edit introduced new warning-or-higher diagnostics: " + + +def make_agent() -> SerenaAgent: + """Create an LSP-backed Serena agent for the Serena repository.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(REPO_PATH), + project_config=ProjectConfig( + project_name="demo_serena_repo", + languages=[Language.PYTHON], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project="demo_serena_repo", serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +def parse_json_result(result: str) -> object: + """Parse and pretty-print JSON tool output.""" + parsed = json.loads(result) + pprint(parsed, width=200) + return parsed + + +def parse_edit_diagnostics_result(result: str) -> dict: + """Extract the grouped diagnostics payload from an edit-tool result.""" + assert result.startswith(EDIT_RESULT_PREFIX), result + return json.loads(result[len(EDIT_RESULT_PREFIX) :]) + + +if __name__ == "__main__": + Tool._ENABLE_DIAGNOSTICS = True + + temp_dir = Path(tempfile.mkdtemp(prefix="serena_demo_", dir=REPO_PATH)) + temp_file = temp_dir / "demo_temp_diagnostics.py" + relative_path = temp_file.relative_to(REPO_PATH).as_posix() + + initial_content = """def demo_existing_issue() -> int: + value = 1 + return value +""" + + agent = make_agent() + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + create_text_file_tool = agent.get_tool(CreateTextFileTool) + replace_content_tool = agent.get_tool(ReplaceContentTool) + get_diagnostics_for_file_tool = agent.get_tool(GetDiagnosticsForFileTool) + get_diagnostics_for_symbol_tool = agent.get_tool(GetDiagnosticsForSymbolTool) + + # creating a clean temporary file + print_section("Create Temporary File") + create_result = agent.execute_task(lambda: create_text_file_tool.apply(relative_path=relative_path, content=initial_content)) + print(create_result) + + # showing file diagnostics before introducing any warning + print_section("Initial File Diagnostics") + initial_diagnostics_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + initial_diagnostics = parse_json_result(initial_diagnostics_result) + assert initial_diagnostics == {}, initial_diagnostics + + # introducing the first warning + print_section("First Edit Result") + first_edit_result = agent.execute_task( + lambda: replace_content_tool.apply( + relative_path=relative_path, + needle="value = 1", + repl="value = missing_one", + mode="literal", + ) + ) + print(first_edit_result) + first_edit_diagnostics = parse_edit_diagnostics_result(first_edit_result) + pprint(first_edit_diagnostics, width=200) + assert "missing_one" in json.dumps(first_edit_diagnostics), first_edit_diagnostics + + # showing the file- and symbol-level diagnostics after the first warning + print_section("File Diagnostics After First Edit") + diagnostics_after_first_edit_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + diagnostics_after_first_edit = parse_json_result(diagnostics_after_first_edit_result) + assert "missing_one" in json.dumps(diagnostics_after_first_edit), diagnostics_after_first_edit + + print_section("Symbol Diagnostics After First Edit") + symbol_diagnostics_result = agent.execute_task( + lambda: get_diagnostics_for_symbol_tool.apply( + name_path="demo_existing_issue", + reference_file=relative_path, + min_severity=2, + ) + ) + symbol_diagnostics = parse_json_result(symbol_diagnostics_result) + assert "missing_one" in json.dumps(symbol_diagnostics), symbol_diagnostics + + # introducing a second warning while keeping the first one unchanged + print_section("Second Edit Result") + second_edit_result = agent.execute_task( + lambda: replace_content_tool.apply( + relative_path=relative_path, + needle=" return value\n", + repl=" other = missing_two\n return value + other\n", + mode="literal", + ) + ) + print(second_edit_result) + second_edit_diagnostics = parse_edit_diagnostics_result(second_edit_result) + pprint(second_edit_diagnostics, width=200) + second_edit_json = json.dumps(second_edit_diagnostics) + assert "missing_two" in second_edit_json, second_edit_diagnostics + assert "missing_one" not in second_edit_json, second_edit_diagnostics + print("\nVerified: the second edit result reports only the newly introduced warning.") + + # showing the complete file diagnostics after both warnings exist + print_section("File Diagnostics After Second Edit") + diagnostics_after_second_edit_result = agent.execute_task( + lambda: get_diagnostics_for_file_tool.apply(relative_path=relative_path, min_severity=2) + ) + diagnostics_after_second_edit = parse_json_result(diagnostics_after_second_edit_result) + diagnostics_after_second_edit_json = json.dumps(diagnostics_after_second_edit) + assert "missing_one" in diagnostics_after_second_edit_json, diagnostics_after_second_edit + assert "missing_two" in diagnostics_after_second_edit_json, diagnostics_after_second_edit + finally: + agent.shutdown() + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py new file mode 100644 index 00000000..f65b820c --- /dev/null +++ b/scripts/demo_find_defining_symbol.py @@ -0,0 +1,128 @@ +""" +Demonstrates both defining-symbol tools on the Python test repository. +""" + +import json +import re +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import FindDefiningSymbolAtLocationTool, FindDefiningSymbolTool +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +PYTHON_TEST_REPO = Path(REPO_ROOT) / "test" / "resources" / "repos" / "python" / "test_repo" +SERVICES_FILE = Path("test_repo") / "services.py" + + +def make_agent(project_root: Path, language: Language, project_name: str) -> SerenaAgent: + """Create an LSP-backed Serena agent for a single explicit project.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(project_root), + project_config=ProjectConfig( + project_name=project_name, + languages=[language], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project=project_name, serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +def find_identifier_occurrence_position(file_path: Path, identifier: str, occurrence_index: int = 0) -> tuple[int, int]: + """Find the 0-based position of an identifier occurrence in a file.""" + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + current_occurrence_index = 0 + with file_path.open(encoding="utf-8") as f: + for line_index, line in enumerate(f): + for match in pattern.finditer(line): + if current_occurrence_index == occurrence_index: + return line_index, match.start() + current_occurrence_index += 1 + raise ValueError(f"Could not find occurrence {occurrence_index} of {identifier!r} in {file_path}") + + +if __name__ == "__main__": + agent = make_agent(PYTHON_TEST_REPO, Language.PYTHON, "demo_python_test_repo") + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + relative_path = SERVICES_FILE.as_posix() + services_abs_path = PYTHON_TEST_REPO / SERVICES_FILE + + # resolving via exact location + line, column = find_identifier_occurrence_position(services_abs_path, "User", occurrence_index=1) + find_by_location_tool = agent.get_tool(FindDefiningSymbolAtLocationTool) + location_result = agent.execute_task( + lambda: find_by_location_tool.apply( + relative_path=relative_path, + line=line, + column=column, + include_info=True, + ) + ) + + print_section("FindDefiningSymbolAtLocationTool") + location_symbol = json.loads(location_result) + pprint(location_symbol, width=200) + + # resolving via regex over the full file + find_by_regex_tool = agent.get_tool(FindDefiningSymbolTool) + regex_result = agent.execute_task( + lambda: find_by_regex_tool.apply( + regex=r"from \.models import Item, (User)", + relative_path=relative_path, + include_info=True, + ) + ) + + print_section("FindDefiningSymbolTool (File Regex)") + regex_symbol = json.loads(regex_result) + pprint(regex_symbol, width=200) + + # resolving via regex restricted to one containing symbol body + contained_regex_result = agent.execute_task( + lambda: find_by_regex_tool.apply( + regex=r"=\s+(User)\(", + relative_path=relative_path, + containing_symbol_name_path="UserService/create_user", + include_info=True, + ) + ) + + print_section("FindDefiningSymbolTool (Contained Regex)") + contained_regex_symbol = json.loads(contained_regex_result) + pprint(contained_regex_symbol, width=200) + + # validating the demonstrated result + for symbol in [location_symbol, regex_symbol, contained_regex_symbol]: + assert symbol is not None, "Expected a defining symbol result" + assert symbol.get("relative_path") is not None + assert "models.py" in symbol["relative_path"], symbol + assert "User" in json.dumps(symbol), symbol + print("\nVerified definition target: User in models.py") + finally: + agent.shutdown() diff --git a/scripts/demo_find_implementing_symbol.py b/scripts/demo_find_implementing_symbol.py new file mode 100644 index 00000000..a19d4b94 --- /dev/null +++ b/scripts/demo_find_implementing_symbol.py @@ -0,0 +1,76 @@ +""" +Demonstrates FindImplementationsTool on the Go test repository. +""" + +import json +from pathlib import Path +from pprint import pprint + +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig +from serena.constants import REPO_ROOT +from serena.project import Project +from serena.tools import FindImplementationsTool +from solidlsp.ls_config import Language + +SEPARATOR = "=" * 80 +GO_TEST_REPO = Path(REPO_ROOT) / "test" / "resources" / "repos" / "go" / "test_repo" + + +def make_agent(project_root: Path, language: Language, project_name: str) -> SerenaAgent: + """Create an LSP-backed Serena agent for a single explicit project.""" + serena_config = SerenaConfig.from_config_file() + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + project = Project( + project_root=str(project_root), + project_config=ProjectConfig( + project_name=project_name, + languages=[language], + ignored_paths=[], + excluded_tools=[], + read_only=False, + ignore_all_files_in_gitignore=True, + initial_prompt="", + encoding="utf-8", + ), + serena_config=serena_config, + ) + serena_config.projects = [RegisteredProject.from_project_instance(project)] + return SerenaAgent(project=project_name, serena_config=serena_config) + + +def print_section(title: str) -> None: + """Print a visibly separated section header.""" + print(f"\n{SEPARATOR}") + print(title) + print(SEPARATOR) + + +if __name__ == "__main__": + agent = make_agent(GO_TEST_REPO, Language.GO, "demo_go_test_repo") + + try: + # letting the language server finish startup + agent.execute_task(lambda: None) + + # running the implementation lookup + find_implementations_tool = agent.get_tool(FindImplementationsTool) + result = agent.execute_task( + lambda: find_implementations_tool.apply( + name_path="Greeter/FormatGreeting", + relative_path="main.go", + include_info=True, + ) + ) + + print_section("Find Implementations Result") + implementations = json.loads(result) + pprint(implementations, width=200) + + # validating the demonstrated result + assert any(implementation["name_path"] == "(ConsoleGreeter).FormatGreeting" for implementation in implementations), result + print("\nVerified implementation target: (ConsoleGreeter).FormatGreeting") + finally: + agent.shutdown() diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 2392d2ba..7d52f2b8 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -4,6 +4,7 @@ import os from abc import ABC, abstractmethod from collections.abc import Iterable, Iterator, Reversible from contextlib import contextmanager +from dataclasses import dataclass from typing import Generic, TypeVar, cast from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient @@ -18,11 +19,18 @@ log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) +@dataclass(frozen=True) +class EditedFilePath: + before_relative_path: str + after_relative_path: str + + class CodeEditor(Generic[TSymbol], ABC): def __init__(self, project: Project) -> None: self.project_root = project.project_root self.encoding = project.project_config.encoding self.newline = project.line_ending.newline_str + self._last_edited_file_paths: list[EditedFilePath] = [] class EditedFile(ABC): def __init__(self, relative_path: str) -> None: @@ -83,6 +91,20 @@ class CodeEditor(Generic[TSymbol], ABC): with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f: f.write(new_contents) + def _set_last_edited_file_paths(self, edited_file_paths: Iterable[EditedFilePath]) -> None: + unique_paths: list[EditedFilePath] = [] + seen_keys: set[tuple[str, str]] = set() + for edited_file_path in edited_file_paths: + key = (edited_file_path.before_relative_path, edited_file_path.after_relative_path) + if key in seen_keys: + continue + seen_keys.add(key) + unique_paths.append(edited_file_path) + self._last_edited_file_paths = unique_paths + + def get_last_edited_file_paths(self) -> list[EditedFilePath]: + return list(self._last_edited_file_paths) + @abstractmethod def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol: """ @@ -114,6 +136,8 @@ class CodeEditor(Generic[TSymbol], ABC): edited_file.delete_text_between_positions(start_pos, end_pos) edited_file.insert_text_at_position(start_pos, body) + self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) + @staticmethod def _count_leading_newlines(text: Iterable) -> int: cnt = 0 @@ -171,6 +195,8 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, col), body) + self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) + def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None: """ Inserts content before the symbol with the given name in the given file. @@ -199,6 +225,8 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body) + self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) + def insert_at_line(self, relative_path: str, line: int, content: str) -> None: """ Inserts content at the given line in the given file. @@ -210,6 +238,8 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, 0), content) + self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)]) + def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None: """ Deletes lines in the given file. @@ -226,6 +256,8 @@ class CodeEditor(Generic[TSymbol], ABC): end_pos = PositionInFile(line=end_line_for_delete, col=end_col) edited_file.delete_text_between_positions(start_pos, end_pos) + self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)]) + def delete_symbol(self, name_path: str, relative_file_path: str) -> None: """ Deletes the symbol with the given name in the given file. @@ -236,6 +268,8 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.delete_text_between_positions(start_pos, end_pos) + self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) + @abstractmethod def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str: pass @@ -292,6 +326,10 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): def apply(self) -> None: pass + @abstractmethod + def get_edited_file_paths(self) -> list[EditedFilePath]: + pass + class EditOperationFileTextEdits(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", file_uri: str, text_edits: list[ls_types.TextEdit]): self._code_editor = code_editor @@ -303,6 +341,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file) edited_file.apply_text_edits(self._text_edits) + def get_edited_file_paths(self) -> list[EditedFilePath]: + return [EditedFilePath(self._relative_path, self._relative_path)] + class EditOperationRenameFile(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", old_uri: str, new_uri: str): self._code_editor = code_editor @@ -314,6 +355,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): new_abs_path = os.path.join(self._code_editor.project_root, self._new_relative_path) os.rename(old_abs_path, new_abs_path) + def get_edited_file_paths(self) -> list[EditedFilePath]: + return [EditedFilePath(self._old_relative_path, self._new_relative_path)] + def _workspace_edit_to_edit_operations(self, workspace_edit: ls_types.WorkspaceEdit) -> list["LanguageServerCodeEditor.EditOperation"]: operations: list[LanguageServerCodeEditor.EditOperation] = [] @@ -343,6 +387,15 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): :return: number of edit operations applied """ operations = self._workspace_edit_to_edit_operations(workspace_edit) + + # recording the affected files + edited_file_paths: list[EditedFilePath] = [] + for operation in operations: + edited_file_paths.extend(operation.get_edited_file_paths()) + + self._set_last_edited_file_paths(edited_file_paths) + + # applying the edit operations for operation in operations: operation.apply() return len(operations) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 4a1b14cc..b44339e1 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -3,6 +3,7 @@ import json import logging import os from abc import ABC, abstractmethod +from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Sequence from dataclasses import asdict, dataclass from time import perf_counter @@ -11,7 +12,7 @@ from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar from sensai.util.string import ToStringMixin import serena.jetbrains.jetbrains_types as jb -from solidlsp import SolidLanguageServer +from solidlsp import SolidLanguageServer, ls_types from solidlsp.ls import LSPFileBuffer from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation @@ -869,6 +870,264 @@ class LanguageServerSymbolRetriever: return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] + def find_implementing_symbols( + self, + name_path: str, + relative_file_path: str, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[LanguageServerSymbol]: + """ + Find all symbols that implement the specified symbol, which is assumed to be unique. + + :param name_path: the name path of the symbol to find implementations for. While this can be a matching pattern, + it should usually be the full path to ensure uniqueness. + :param relative_file_path: the relative path of the file in which the implemented symbol is defined. + :param include_body: whether to include the body of all symbols in the result. + :param include_kinds: which kinds of symbols to include in the result. + :param exclude_kinds: which kinds of symbols to exclude from the result. + """ + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path) + return self.find_implementing_symbols_by_location( + symbol.location, + include_body=include_body, + include_kinds=include_kinds, + exclude_kinds=exclude_kinds, + ) + + def find_implementing_symbols_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[LanguageServerSymbol]: + """ + Find all symbols that implement the symbol at the given location. + + :param symbol_location: the location of the symbol for which to find implementations. + Does not need to include an end_line, as it is unused in the search. + :param include_body: whether to include the body of all symbols in the result. + :param include_kinds: an optional sequence of ints representing the LSP symbol kind. + If provided, only symbols of the given kinds will be included in the result. + :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. + Takes precedence over include_kinds. + :return: a list of symbols that implement the given symbol + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + implementing_symbols = lang_server.request_implementing_symbols( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + include_body=include_body, + ) + + if include_kinds is not None: + implementing_symbols = [s for s in implementing_symbols if s["kind"] in include_kinds] + + if exclude_kinds is not None: + implementing_symbols = [s for s in implementing_symbols if s["kind"] not in exclude_kinds] + + return [LanguageServerSymbol(s) for s in implementing_symbols] + + def find_defining_symbol( + self, + relative_file_path: str, + line: int, + column: int, + include_body: bool = False, + ) -> LanguageServerSymbol | None: + """ + Find the symbol that defines the symbol at the given file position. + + :param relative_file_path: the relative path to the file in which the symbol usage occurs. + :param line: the 0-based line number of the symbol usage. + :param column: the 0-based column number of the symbol usage. + :param include_body: whether to include the body of the defining symbol in the result. + :return: the defining symbol, or None if no definition could be resolved. + """ + lang_server = self.get_language_server(relative_file_path) + defining_symbol = lang_server.request_defining_symbol( + relative_file_path=relative_file_path, + line=line, + column=column, + include_body=include_body, + ) + if defining_symbol is None: + return None + return LanguageServerSymbol(defining_symbol) + + def get_file_diagnostics( + self, + relative_file_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + ) -> list[ls_types.Diagnostic]: + """ + Get diagnostics for a file, optionally restricted to a line range and minimum severity. + + :param relative_file_path: the relative path to the file. + :param start_line: the first 0-based line to include. + :param end_line: the last 0-based line to include. `-1` means until end of file. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: the diagnostics matching the requested constraints. + """ + lang_server = self.get_language_server(relative_file_path) + return lang_server.request_text_document_diagnostics( + relative_file_path=relative_file_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + @staticmethod + def _symbol_identity(symbol: LanguageServerSymbol) -> tuple[str | None, int | None, int | None, str]: + return (symbol.relative_path, symbol.line, symbol.column, symbol.get_name_path()) + + @staticmethod + def _normalize_symbol_for_diagnostics(symbol: LanguageServerSymbol) -> LanguageServerSymbol: + current_symbol = symbol + while current_symbol.is_low_level(): + parent_symbol = current_symbol.get_parent() + if parent_symbol is None: + break + current_symbol = parent_symbol + return current_symbol + + def find_diagnostic_owner_symbol(self, relative_file_path: str, line: int, column: int) -> LanguageServerSymbol | None: + """ + Find the symbol that should own a diagnostic at the given position. + + This prefers the structural container of the diagnostic over low-level symbols such as + local variables, because diagnostics are typically more meaningful when grouped by the + surrounding function, method, class, or analogous construct. + + :param relative_file_path: the relative path to the file containing the diagnostic. + :param line: the 0-based line of the diagnostic. + :param column: the 0-based column of the diagnostic. + :return: the owning symbol, or None if no symbol could be resolved. + """ + lang_server = self.get_language_server(relative_file_path) + symbol_dict = lang_server.request_symbol_at_location( + relative_file_path=relative_file_path, + line=line, + column=column, + ) + if symbol_dict is None: + return None + return self._normalize_symbol_for_diagnostics(LanguageServerSymbol(symbol_dict)) + + def _get_diagnostics_for_symbol(self, symbol: LanguageServerSymbol, min_severity: int) -> list[ls_types.Diagnostic]: + relative_path = symbol.relative_path + if relative_path is None: + return [] + + start_line, end_line = symbol.get_body_line_numbers() + if start_line is None: + if symbol.line is None: + return [] + start_line = symbol.line + if end_line is None: + end_line = start_line + + return self.get_file_diagnostics( + relative_file_path=relative_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + def get_symbol_diagnostics( + self, + name_path: str, + reference_file: str | None = None, + check_symbol_references: bool = False, + min_severity: int = 4, + ) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]: + """ + Get diagnostics for the specified symbol and, optionally, for all symbols that reference it. + + :param name_path: the name path of the symbol to find. It should usually be unique. + :param reference_file: optional file path used to disambiguate the symbol search. + :param check_symbol_references: whether to additionally collect diagnostics for referencing symbols. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: a mapping from symbols to the diagnostics that overlap their body ranges. + """ + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=reference_file or None) + return self.get_symbol_diagnostics_by_location( + symbol.location, + check_symbol_references=check_symbol_references, + min_severity=min_severity, + ) + + def get_symbol_diagnostics_by_location( + self, + symbol_location: LanguageServerSymbolLocation, + check_symbol_references: bool = False, + min_severity: int = 4, + ) -> dict[LanguageServerSymbol, list[ls_types.Diagnostic]]: + """ + Get diagnostics for the symbol at the given location and, optionally, for all referencing symbols. + + :param symbol_location: location of the symbol to inspect. + :param check_symbol_references: whether to additionally collect diagnostics for referencing symbols. + :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. + :return: an ordered mapping from symbols to the diagnostics that overlap their body ranges. + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + + symbol = self.find_by_location(symbol_location) + if symbol is None: + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + lang_server = self.get_language_server(symbol_location.relative_path) + symbol_dict = lang_server.request_symbol_at_location( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + ) + if symbol_dict is None: + return {} + symbol = LanguageServerSymbol(symbol_dict) + + symbols_to_check: "OrderedDict[tuple[str | None, int | None, int | None, str], LanguageServerSymbol]" = OrderedDict() + symbols_to_check[self._symbol_identity(symbol)] = symbol + + if check_symbol_references: + reference_symbols = self.find_referencing_symbols_by_location( + symbol.location, + include_body=False, + exclude_kinds=[SymbolKind.File, SymbolKind.Module, SymbolKind.Package, SymbolKind.Namespace], + ) + for reference in reference_symbols: + reference_relative_path = reference.get_relative_path() + normalized_reference_symbol = None + if reference_relative_path is not None: + normalized_reference_symbol = self.find_diagnostic_owner_symbol( + relative_file_path=reference_relative_path, + line=reference.line, + column=reference.character, + ) + if normalized_reference_symbol is None: + normalized_reference_symbol = self._normalize_symbol_for_diagnostics(reference.symbol) + symbols_to_check.setdefault(self._symbol_identity(normalized_reference_symbol), normalized_reference_symbol) + + result: dict[LanguageServerSymbol, list[ls_types.Diagnostic]] = {} + for current_symbol in symbols_to_check.values(): + diagnostics = self._get_diagnostics_for_symbol(current_symbol, min_severity=min_severity) + if diagnostics: + result[current_symbol] = diagnostics + return result + def get_symbol_overview(self, relative_path: str) -> dict[str, list[LanguageServerSymbol]]: """ :param relative_path: the path of the file for which to get the symbol overview diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 4e7268e0..42923a03 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -12,6 +12,7 @@ from fnmatch import fnmatch from pathlib import Path from typing import Literal +from serena.code_editor import EditedFilePath from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional from serena.util.file_system import scan_directory from serena.util.text_utils import ContentReplacer, search_files @@ -61,6 +62,11 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): :param content: the (appropriately encoded) content to write to the file :return: a message indicating success or failure """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # validating the destination path project_root = self.get_project_root() abs_path = (Path(project_root) / relative_path).resolve() will_overwrite_existing = abs_path.exists() @@ -72,12 +78,14 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): f"Cannot create file outside of the project directory, got {relative_path=}" ) + # writing the file abs_path.parent.mkdir(parents=True, exist_ok=True) abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) answer = f"File created: {relative_path}." if will_overwrite_existing: answer += " Overwrote existing file." - return answer + + return self._format_lsp_edit_result_with_new_diagnostics(answer, edited_file_paths, diagnostics_snapshot) class ListDirTool(Tool): @@ -161,6 +169,8 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): Replaces content in a file (optionally using regular expressions). """ + _CAPTURE_DIAGNOSTICS: bool = False + def apply( self, relative_path: str, @@ -212,13 +222,19 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): Performs the replacement, with additional options not exposed in the tool. This function can be used internally by other tools. """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the content replacement self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) with EditedFileContext(relative_path, self.create_code_editor()) as context: original_content = context.get_original_content() replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) updated_content = replacer.replace(original_content, needle, repl) context.set_updated_content(updated_content) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): @@ -241,9 +257,15 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param start_line: the 0-based index of the first line to be deleted :param end_line: the 0-based index of the last line to be deleted """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the edit code_editor = self.create_code_editor() code_editor.delete_lines(relative_path, start_line, end_line) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): @@ -268,13 +290,20 @@ class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param end_line: the 0-based index of the last line to be deleted :param content: the content to insert """ + # normalizing the replacement content if not content.endswith("\n"): content += "\n" - result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line) - if result != SUCCESS_RESULT: - return result - self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content) - return SUCCESS_RESULT + + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the replacement + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + code_editor.insert_at_line(relative_path, start_line, content) + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): @@ -298,11 +327,19 @@ class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): :param line: the 0-based index of the line to insert content at :param content: the content to be inserted """ + # normalizing the inserted content if not content.endswith("\n"): content += "\n" + + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the insertion code_editor = self.create_code_editor() code_editor.insert_at_line(relative_path, line, content) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class SearchForPatternTool(Tool): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index a19a99b9..2a44a14e 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -4,9 +4,12 @@ Language server-related tools import copy import os +import re from collections import Counter, defaultdict from collections.abc import Sequence +from typing import Any +from serena.code_editor import EditedFilePath from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, @@ -15,7 +18,72 @@ from serena.tools import ( ToolMarkerSymbolicRead, ) from serena.tools.tools_base import ToolMarkerOptional +from solidlsp import ls_types from solidlsp.ls_types import SymbolKind +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity + +FILE_LEVEL_DIAGNOSTIC_BUCKET = "" + + +def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + +def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result + + +def _add_grouped_diagnostic( + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], + relative_path: str, + severity_name: str, + name_path: str, + diagnostic: ls_types.Diagnostic, +) -> None: + grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + _diagnostic_output_dict(diagnostic) + ) + + +def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]: + if offset < 0 or offset > len(text): + raise ValueError(f"Offset out of range: {offset}") + prefix = text[:offset] + line = prefix.count("\n") + previous_newline_offset = prefix.rfind("\n") + column = offset if previous_newline_offset == -1 else offset - previous_newline_offset - 1 + return line, column + + +def _line_and_column_to_offset(text: str, line: int, column: int) -> int: + if line < 0 or column < 0: + raise ValueError(f"Line and column must be non-negative, got {line=}, {column=}") + + line_start_offsets = [0] + for match in re.finditer("\n", text): + line_start_offsets.append(match.end()) + + if line >= len(line_start_offsets): + raise ValueError(f"Line out of range: {line}") + + line_start_offset = line_start_offsets[line] + line_end_offset = line_start_offsets[line + 1] - 1 if line + 1 < len(line_start_offsets) else len(text) + if column > line_end_offset - line_start_offset: + raise ValueError(f"Column out of range for line {line}: {column}") + return line_start_offset + column class RestartLanguageServerTool(Tool, ToolMarkerOptional): @@ -324,6 +392,385 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): return self._limit_length(result_json, max_answer_chars, shortened_result_factories=shortened_results) +class FindImplementationsTool(Tool, ToolMarkerSymbolicRead): + """ + Finds symbols that implement the given symbol using the language server backend. + """ + + # noinspection PyDefaultArgument + def apply( + self, + name_path: str, + relative_path: str, + include_info: bool = False, + include_kinds: list[int] = [], # noqa: B006 + exclude_kinds: list[int] = [], # noqa: B006 + max_answer_chars: int = -1, + ) -> str: + """ + Finds implementations of the symbol at the given `name_path`. + + :param name_path: for finding the symbol to find implementations for, same logic as in the `find_symbol` tool. + :param relative_path: the relative path to the file containing the symbol for which to find implementations. + Note that here you can't pass a directory but must pass a file. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature), + about the implementing symbols. + :param include_kinds: same as in the `find_symbol` tool. + :param exclude_kinds: same as in the `find_symbol` tool. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: a list of JSON objects with the symbols implementing the requested symbol + """ + 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 + symbol_retriever = self.create_language_server_symbol_retriever() + + implementing_symbols = symbol_retriever.find_implementing_symbols( + name_path, + relative_file_path=relative_path, + include_body=include_body, + include_kinds=parsed_include_kinds, + exclude_kinds=parsed_exclude_kinds, + ) + + symbol_dicts = [ + dict(s.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) for s in implementing_symbols + ] + if include_info: + info_by_symbol = symbol_retriever.request_info_for_symbol_batch(implementing_symbols) + for s, s_dict in zip(implementing_symbols, symbol_dicts, strict=True): + if symbol_info := info_by_symbol.get(s): + s_dict["info"] = symbol_info + s_dict.pop("name", None) # name is included in the info + + result = self._to_json(symbol_dicts) + return self._limit_length(result, max_answer_chars) + + +class FindDefiningSymbolAtLocationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Finds the symbol that defines the symbol at the given file position using the language server backend. + """ + + @staticmethod + def _defining_symbol_to_result_dict( + symbol_retriever: Any, + defining_symbol: LanguageServerSymbol | None, + include_body: bool, + include_info: bool, + ) -> dict[str, Any] | None: + if defining_symbol is None: + return None + + symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) + if not include_body and include_info: + if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol): + symbol_dict["info"] = symbol_info + symbol_dict.pop("name", None) + return symbol_dict + + # noinspection PyDefaultArgument + def apply( + self, + relative_path: str, + line: int, + column: int, + include_body: bool = False, + include_info: bool = False, + max_answer_chars: int = -1, + ) -> str: + """ + Finds the defining symbol for the symbol at the given position. + + :param relative_path: the relative path to the file containing the symbol usage. + :param line: the 0-based line number of the symbol usage. + :param column: the 0-based column number of the symbol usage. + :param include_body: whether to include the source code of the defining symbol. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature) + about the defining symbol. Ignored if no defining symbol is found. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: a JSON object representing the defining symbol, or `null` if no definition was found. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + defining_symbol = symbol_retriever.find_defining_symbol( + relative_file_path=relative_path, + line=line, + column=column, + include_body=include_body, + ) + + if defining_symbol is None: + result = self._to_json(None) + return self._limit_length(result, max_answer_chars) + + symbol_dict = self._defining_symbol_to_result_dict(symbol_retriever, defining_symbol, include_body, include_info) + assert symbol_dict is not None + result = self._to_json(symbol_dict) + return self._limit_length(result, max_answer_chars) + + +class FindDefiningSymbolTool(Tool, ToolMarkerSymbolicRead): + """ + Finds the symbol that defines a uniquely captured regex match in a file or containing symbol body. + """ + + @staticmethod + def _describe_search_scope(relative_path: str, containing_symbol_name_path: str | None) -> str: + if containing_symbol_name_path: + return f"symbol body '{containing_symbol_name_path}' in file '{relative_path}'" + return f"file '{relative_path}'" + + @classmethod + def _format_match_preview(cls, match: re.Match[str]) -> str: + matched_text = match.group(0).replace("\n", "\\n") + return matched_text[:120] + + @classmethod + def _get_unique_captured_span(cls, match: re.Match[str], regex: str, search_scope_description: str) -> tuple[int, int] | str: + if match.re.groups == 0: + return ( + f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in " + f"{search_scope_description}." + ) + + matched_capture_spans = [span for span in match.regs[1:] if span != (-1, -1)] + if len(matched_capture_spans) != 1: + return ( + f"Error: Regex '{regex}' must produce exactly one matched capture in {search_scope_description}, " + f"but produced {len(matched_capture_spans)} for match '{cls._format_match_preview(match)}'." + ) + + capture_start_offset, capture_end_offset = matched_capture_spans[0] + if capture_start_offset == capture_end_offset: + return ( + f"Error: Regex '{regex}' produced an empty capture in {search_scope_description}; " + "the capture must select the referenced symbol text." + ) + return capture_start_offset, capture_end_offset + + def _find_unique_captured_location( + self, + relative_path: str, + regex: str, + containing_symbol_name_path: str | None, + ) -> tuple[int, int] | str: + # retrieving the search region + file_content = self.project.read_file(relative_path) + symbol_retriever = self.create_language_server_symbol_retriever() + search_scope_description = self._describe_search_scope(relative_path, containing_symbol_name_path) + search_start_offset = 0 + search_text = file_content + + if containing_symbol_name_path: + try: + containing_symbol = symbol_retriever.find_unique(containing_symbol_name_path, within_relative_path=relative_path) + except Exception as e: + return f"Error: Could not resolve containing symbol '{containing_symbol_name_path}' in file '{relative_path}': {e}" + + body_start_position = containing_symbol.get_body_start_position_or_raise() + body_end_position = containing_symbol.get_body_end_position_or_raise() + search_start_offset = _line_and_column_to_offset(file_content, body_start_position.line, body_start_position.col) + search_end_offset = _line_and_column_to_offset(file_content, body_end_position.line, body_end_position.col) + search_text = file_content[search_start_offset:search_end_offset] + + # finding regex matches + try: + compiled_regex = re.compile(regex, re.MULTILINE) + except re.error as e: + return f"Error: Invalid regex '{regex}': {e}" + + if compiled_regex.groups == 0: + return ( + f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in " + f"{search_scope_description}." + ) + + matches = list(compiled_regex.finditer(search_text)) + if len(matches) != 1: + match_previews = [self._format_match_preview(match) for match in matches[:3]] + preview_suffix = f" Matches: {match_previews}" if match_previews else "" + return ( + f"Error: Expected exactly one regex match for '{regex}' in {search_scope_description}, " + f"but found {len(matches)}.{preview_suffix}" + ) + + capture_span_or_error = self._get_unique_captured_span(matches[0], regex, search_scope_description) + if isinstance(capture_span_or_error, str): + return capture_span_or_error + + capture_start_offset, _ = capture_span_or_error + absolute_capture_start_offset = search_start_offset + capture_start_offset + return _offset_to_line_and_column(file_content, absolute_capture_start_offset) + + # noinspection PyDefaultArgument + def apply( + self, + regex: str, + relative_path: str, + containing_symbol_name_path: str = "", + include_body: bool = False, + include_info: bool = False, + max_answer_chars: int = -1, + ) -> str: + r""" + Finds the defining symbol for a uniquely captured regex match in a file. + + The regex must contain exactly one capturing group, and exactly one overall match must be found. + The capture identifies the symbol usage whose definition should be resolved. + + The regex is compiled with ``re.MULTILINE`` enabled and ``re.DOTALL`` disabled. This keeps common + single-symbol matches predictable. If cross-line matching is needed, opt in explicitly with ``(?s)`` + or ``[\\s\\S]*?``. + + :param regex: a Python regular expression containing exactly one capturing group. + :param relative_path: the relative path to the file containing the symbol usage. + :param containing_symbol_name_path: optional name path of a containing symbol whose body shall be searched instead of the full file. + :param include_body: whether to include the source code of the defining symbol. + :param include_info: whether to include additional info (hover-like, typically including docstring and signature) + about the defining symbol. Ignored if no defining symbol is found. + :param max_answer_chars: same as in the `find_symbol` tool. + :return: a JSON object representing the defining symbol, or ``null`` if no definition was found. + If the regex does not identify a unique captured match, an informative error string is returned. + """ + captured_location_or_error = self._find_unique_captured_location( + relative_path=relative_path, + regex=regex, + containing_symbol_name_path=containing_symbol_name_path or None, + ) + if isinstance(captured_location_or_error, str): + return captured_location_or_error + + line, column = captured_location_or_error + symbol_retriever = self.create_language_server_symbol_retriever() + defining_symbol = symbol_retriever.find_defining_symbol( + relative_file_path=relative_path, + line=line, + column=column, + include_body=include_body, + ) + symbol_dict = FindDefiningSymbolAtLocationTool._defining_symbol_to_result_dict( + symbol_retriever, + defining_symbol, + include_body, + include_info, + ) + result = self._to_json(symbol_dict) + return self._limit_length(result, max_answer_chars) + + +class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): + """ + Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol. + """ + + _ENABLE_DIAGNOSTICS: bool = True + + def apply( + self, + relative_path: str, + start_line: int = 0, + end_line: int = -1, + min_severity: int = 4, + max_answer_chars: int = -1, + ) -> str: + """ + Gets diagnostics for a file. Diagnostics are grouped as `relative_path -> severity -> name_path -> diagnostics_results`. + If a diagnostic cannot be mapped to a symbol, it is grouped under the special name path ``. + + :param relative_path: the relative path to the file to inspect. + :param start_line: the first 0-based line to include. Defaults to 0. + :param end_line: the last 0-based line to include. Defaults to -1, which means until the end of the file. + :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 max_answer_chars: same as in the `find_symbol` tool. + :return: grouped diagnostics for the requested file. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + diagnostics = symbol_retriever.get_file_diagnostics( + relative_file_path=relative_path, + start_line=start_line, + end_line=end_line, + min_severity=min_severity, + ) + + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + for diagnostic in diagnostics: + diag_range = diagnostic["range"]["start"] + name_path = FILE_LEVEL_DIAGNOSTIC_BUCKET + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=relative_path, + line=diag_range["line"], + column=diag_range["character"], + ) + if owner_symbol is not None: + name_path = owner_symbol.get_name_path() + _add_grouped_diagnostic( + grouped_result, + relative_path=relative_path, + severity_name=_diagnostic_severity_name(diagnostic.get("severity")), + name_path=name_path, + diagnostic=diagnostic, + ) + + result = self._to_json(grouped_result) + return self._limit_length(result, max_answer_chars) + + +class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Gets diagnostics for a symbol and, optionally, for symbols that reference it. + """ + + _ENABLE_DIAGNOSTICS: bool = True + + def apply( + self, + name_path: str, + reference_file: str = "", + check_symbol_references: bool = False, + min_severity: int = 4, + max_answer_chars: int = -1, + ) -> str: + """ + Gets diagnostics for the specified symbol. When `check_symbol_references` is true, diagnostics for all + referencing symbols are also included. The result is grouped as + `relative_path -> severity -> name_path -> diagnostics_results`. + + :param name_path: the name path of the symbol to inspect. + :param reference_file: optional file path used to disambiguate the symbol search. + :param check_symbol_references: whether to additionally collect diagnostics for symbols that reference the symbol. + :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 max_answer_chars: same as in the `find_symbol` tool. + :return: grouped diagnostics for the requested symbol and, optionally, its referencing symbols. + """ + symbol_retriever = self.create_language_server_symbol_retriever() + diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( + name_path=name_path, + reference_file=reference_file or None, + check_symbol_references=check_symbol_references, + min_severity=min_severity, + ) + + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + for symbol, diagnostics in diagnostics_by_symbol.items(): + relative_path = symbol.relative_path + if relative_path is None: + continue + symbol_name_path = symbol.get_name_path() + for diagnostic in diagnostics: + _add_grouped_diagnostic( + grouped_result, + relative_path=relative_path, + severity_name=_diagnostic_severity_name(diagnostic.get("severity")), + name_path=symbol_name_path, + diagnostic=diagnostic, + ) + + result = self._to_json(grouped_result) + return self._limit_length(result, max_answer_chars) + + class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): """ Replaces the full definition of a symbol using the language server backend. @@ -348,13 +795,19 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): in the programming language, including e.g. the signature line for functions. IMPORTANT: The body does NOT include any preceding docstrings/comments or imports, in particular. """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the symbol replacement code_editor = self.create_code_editor() code_editor.replace_body( name_path, relative_file_path=relative_path, body=body, ) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): @@ -377,9 +830,15 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): :param body: the body/content to be inserted. The inserted code shall begin with the next line after the symbol. """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the insertion code_editor = self.create_code_editor() code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): @@ -402,9 +861,15 @@ class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): :param relative_path: the relative path to the file containing the symbol :param body: the body/content to be inserted before the line in which the referenced symbol is defined """ + # capturing diagnostics before the edit + edited_file_paths = [EditedFilePath(relative_path, relative_path)] + diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + + # applying the insertion code_editor = self.create_code_editor() code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) - return SUCCESS_RESULT + + return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index c13e4cd4..7ef28c3c 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -18,11 +18,13 @@ from serena.project import MemoriesManager, Project from serena.prompt_factory import PromptFactory from serena.util.class_decorators import singleton from serena.util.inspection import iter_subclasses +from solidlsp import ls_types from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity if TYPE_CHECKING: from serena.agent import SerenaAgent - from serena.code_editor import CodeEditor, LanguageServerCodeEditor + from serena.code_editor import CodeEditor, EditedFilePath, LanguageServerCodeEditor from serena.symbol import LanguageServerSymbolRetriever log = logging.getLogger(__name__) @@ -30,6 +32,24 @@ T = TypeVar("T") SUCCESS_RESULT = "OK" +@dataclass(frozen=True) +class DiagnosticIdentity: + message: str + start_line: int + start_character: int + end_line: int + end_character: int + severity: int | None + code_repr: str | None + source: str | None + + +@dataclass +class PublishedDiagnosticsSnapshot: + generation_by_after_path: dict[str, int] + warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] + + class Component(ABC): def __init__(self, agent: "SerenaAgent"): self.agent = agent @@ -145,6 +165,8 @@ class Tool(Component): _last_tool_call_client_str: str | None = None """We can only get the client info from within a tool call. Each tool call will update this variable.""" + _ENABLE_DIAGNOSTICS: bool = False + def __init__(self, agent: "SerenaAgent"): super().__init__(agent) @@ -304,6 +326,180 @@ class Tool(Component): def is_symbolic(self) -> bool: return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) + @staticmethod + def _diagnostic_code_repr(code: Any) -> str | None: + if code is None: + return None + try: + return json.dumps(code, sort_keys=True, ensure_ascii=False) + except TypeError: + return repr(code) + + @classmethod + def _diagnostic_identity(cls, diagnostic: ls_types.Diagnostic) -> DiagnosticIdentity: + diagnostic_range = diagnostic["range"] + start = diagnostic_range["start"] + end = diagnostic_range["end"] + return DiagnosticIdentity( + message=diagnostic["message"], + start_line=start["line"], + start_character=start["character"], + end_line=end["line"], + end_character=end["character"], + severity=diagnostic.get("severity"), + code_repr=cls._diagnostic_code_repr(diagnostic.get("code")), + source=diagnostic.get("source"), + ) + + @staticmethod + def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + @staticmethod + def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result + + @classmethod + def _add_grouped_diagnostic( + cls, + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], + relative_path: str, + name_path: str, + diagnostic: ls_types.Diagnostic, + ) -> None: + severity_name = cls._diagnostic_severity_name(diagnostic.get("severity")) + grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + cls._diagnostic_output_dict(diagnostic) + ) + + def _capture_published_lsp_diagnostics_snapshot( + self, + edited_file_paths: Iterable["EditedFilePath"], + ) -> PublishedDiagnosticsSnapshot | None: + if not self._ENABLE_DIAGNOSTICS: + return None + if self.agent.get_language_backend() != LanguageBackend.LSP: + return None + + # collecting diagnostics state before the edit + symbol_retriever = self.create_language_server_symbol_retriever() + generation_by_after_path: dict[str, int] = {} + warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {} + + for edited_file_path in edited_file_paths: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except Exception: + return None + + generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation( + edited_file_path.after_relative_path + ) + + cached_diagnostics = language_server.get_cached_published_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + if cached_diagnostics is None: + try: + cached_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + except Exception: + cached_diagnostics = [] + warning_identities_by_before_path[edited_file_path.before_relative_path] = { + self._diagnostic_identity(diagnostic) for diagnostic in cached_diagnostics or [] + } + + return PublishedDiagnosticsSnapshot( + generation_by_after_path=generation_by_after_path, + warning_identities_by_before_path=warning_identities_by_before_path, + ) + + def _format_lsp_edit_result_with_new_diagnostics( + self, + default_result: str, + edited_file_paths: Iterable["EditedFilePath"], + before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None, + ) -> str: + if not self._ENABLE_DIAGNOSTICS: + return default_result + + # TODO: this is weird, it works because before_edit_diagnostics_snapshot is only None when diagnostics + # are disabled, not when they are empty. A part of the general design flaws introduced by agent-generated code + if before_edit_diagnostics_snapshot is None or self.agent.get_language_backend() != LanguageBackend.LSP: + return default_result + + # collecting diagnostics state after the edit + symbol_retriever = self.create_language_server_symbol_retriever() + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + saw_diagnostics_result = False + + for edited_file_path in edited_file_paths: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except Exception: + return default_result + + published_diagnostics = language_server.request_published_text_document_diagnostics( + relative_file_path=edited_file_path.after_relative_path, + after_generation=before_edit_diagnostics_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1), + timeout=2.5, + min_severity=2, + allow_cached=True, + ) + if not published_diagnostics: + try: + published_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.after_relative_path, + min_severity=2, + ) + except Exception: + published_diagnostics = None + if published_diagnostics is None: + continue + + saw_diagnostics_result = True + existing_warning_identities = before_edit_diagnostics_snapshot.warning_identities_by_before_path.get( + edited_file_path.before_relative_path, set() + ) + new_warning_identities: set[DiagnosticIdentity] = set() + + for diagnostic in published_diagnostics: + diagnostic_identity = self._diagnostic_identity(diagnostic) + if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities: + continue + new_warning_identities.add(diagnostic_identity) + + diagnostic_start = diagnostic["range"]["start"] + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=edited_file_path.after_relative_path, + line=diagnostic_start["line"], + column=diagnostic_start["character"], + ) + name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" + self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic) + + # preserving the normal success result when no new diagnostics were introduced + if not saw_diagnostics_result or not grouped_result: + return default_result + + return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}" + 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 @@ -319,7 +515,7 @@ class Tool(Component): if client_str != self.get_last_tool_call_client_str(): log.debug(f"Updating client info: {client_info}") self.set_last_tool_call_client_str(client_str) - except BaseException as e: + except Exception as e: log.info(f"Failed to get client info: {e}.") def task() -> str: diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 6ae69a39..546c6a9f 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -14,7 +14,7 @@ from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath from time import perf_counter, sleep -from typing import Self, Union, cast +from typing import Any, Self, Union, cast import pathspec from sensai.util.pickle import getstate, load_pickle @@ -511,6 +511,10 @@ class SolidLanguageServer(ABC): self.language_id = language_id self.open_file_buffers: dict[str, LSPFileBuffer] = {} self.language = Language(language_id) + 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 @@ -550,6 +554,7 @@ class SolidLanguageServer(ABC): logger=logging_fn, start_independent_lsp_process=config.start_independent_lsp_process, ) + 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 @@ -567,6 +572,274 @@ class SolidLanguageServer(ABC): self._has_waited_for_cross_file_references = False + 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)) + + with self._published_diagnostics_condition: + self._published_diagnostics_generation += 1 + self._published_diagnostics[uri] = normalized_diagnostics + self._published_diagnostics_generation_by_uri[uri] = self._published_diagnostics_generation + self._published_diagnostics_condition.notify_all() + + def _get_published_diagnostics_generation(self, uri: str) -> int: + with self._published_diagnostics_condition: + return self._published_diagnostics_generation_by_uri.get(uri, -1) + + def _wait_for_published_diagnostics( + self, + uri: str, + after_generation: int, + timeout: float, + ) -> list[ls_types.Diagnostic] | None: + deadline = perf_counter() + timeout + with self._published_diagnostics_condition: + while True: + current_generation = self._published_diagnostics_generation_by_uri.get(uri, -1) + if current_generation > after_generation: + return list(self._published_diagnostics.get(uri, [])) + + 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: + with self._published_diagnostics_condition: + diagnostics = self._published_diagnostics.get(uri) + 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"] + effective_end_line = end_line if end_line >= 0 else diagnostic_end_line + return diagnostic_start_line <= effective_end_line and diagnostic_end_line >= 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) + diagnostics: list[ls_types.Diagnostic] | None = None + + # keeping the document open + with self.open_file(relative_file_path): + diagnostics = self._wait_for_published_diagnostics(uri=uri, after_generation=after_generation, timeout=timeout) + + # falling back to cached diagnostics + if diagnostics is None and allow_cached: + 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_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) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + ret: list[ls_types.Diagnostic] | None = None + pull_diagnostics_failed = False + + with self.open_file(relative_file_path): + try: + response = self.server.send.text_document_diagnostic( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + 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["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_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=2.5 if pull_diagnostics_failed else 0.5, + ) + if published_diagnostics is None: + published_diagnostics = self._get_cached_published_diagnostics(uri) + 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 _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ Creates the dependency provider for this language server. diff --git a/src/solidlsp/ls_process.py b/src/solidlsp/ls_process.py index 6b979a92..6319a9d7 100644 --- a/src/solidlsp/ls_process.py +++ b/src/solidlsp/ls_process.py @@ -150,6 +150,7 @@ class LanguageServerProcess: self._pending_requests: dict[Any, Request] = {} self.on_request_handlers: dict[str, Callable[[Any], Any]] = {} self.on_notification_handlers: dict[str, Callable[[Any], None]] = {} + self._notification_observers: list[Callable[[str, Any], None]] = [] self._trace_log_fn = logger self.tasks: dict[int, Any] = {} self.task_counter = 0 @@ -510,6 +511,12 @@ class LanguageServerProcess: """ self.on_notification_handlers[method] = cb + def on_any_notification(self, cb: Callable[[str, Any], None]) -> None: + """ + Register an observer that is invoked for every notification received from the server. + """ + self._notification_observers.append(cb) + def _response_handler(self, response: StringDict) -> None: """ Handle the response received from the server for a request, using the id to determine the request @@ -561,6 +568,16 @@ class LanguageServerProcess: """ method = response.get("method", "") params = response.get("params") + + for observer in self._notification_observers: + try: + observer(method, params) + except asyncio.CancelledError: + return + except Exception as ex: + if not self._is_shutting_down: + log.error("Error handling notification observer for method '%s': %s", method, ex, exc_info=ex) + handler = self.on_notification_handlers.get(method) if not handler: log.warning("Unhandled method '%s'", method) diff --git a/src/solidlsp/ls_types.py b/src/solidlsp/ls_types.py index 78446bc5..24d51266 100644 --- a/src/solidlsp/ls_types.py +++ b/src/solidlsp/ls_types.py @@ -347,7 +347,7 @@ class Diagnostic(TypedDict): """ The severity of the diagnostic. """ message: str """ The diagnostic message. """ - code: str + code: NotRequired[str | int] """ The code of the diagnostic. """ source: NotRequired[str] """ The source of the diagnostic, e.g. the name of the tool that produced it. """ diff --git a/test/conftest.py b/test/conftest.py index 4d3f1154..5e55b180 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,6 +1,7 @@ import logging import os import platform +import re import shutil as _sh from collections.abc import Iterator from contextlib import contextmanager @@ -41,10 +42,13 @@ _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.CPP_CCLS: Language.CPP, Language.PHP_PHPACTOR: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, + Language.PYTHON_TY: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, Language.PYTHON_TY: Language.PYTHON, } +PYTHON_LANGUAGE_BACKENDS = [Language.PYTHON, Language.PYTHON_TY] + def get_repo_path(language: Language) -> Path: repo_language = _LANGUAGE_REPO_ALIASES.get(language, language) @@ -329,3 +333,60 @@ def language_tests_enabled(language: Language) -> bool: :return: True if tests for the language are enabled, False otherwise """ return language not in _disabled_languages + + +def language_supports_implementation(language: Language) -> bool: + return language.supports_implementation_request() + + +def languages_supporting_implementation(*languages: Language) -> list[Language]: + return [language for language in languages if language_supports_implementation(language)] + + +_VERIFIED_IMPLEMENTATION_LANGUAGES = { + Language.CSHARP, + Language.GO, + Language.JAVA, + Language.RUST, + Language.TYPESCRIPT, +} + + +def language_has_verified_implementation_support(language: Language) -> bool: + """ + True only for languages where the server advertises implementation support and + the repo fixtures contain a verified working go-to-implementation scenario. + """ + return language in _VERIFIED_IMPLEMENTATION_LANGUAGES and language_supports_implementation(language) + + +def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int] | None: + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + with file_path.open(encoding="utf-8") as f: + for line_idx, line in enumerate(f): + match = pattern.search(line) + if match: + return line_idx, match.start() + return None + + +def find_identifier_occurrence_position( + file_path: Path, + identifier: str, + occurrence_index: int = 0, + column_offset: int = 0, +) -> tuple[int, int] | None: + if occurrence_index < 0: + raise ValueError("occurrence_index must be non-negative") + if column_offset < 0: + raise ValueError("column_offset must be non-negative") + + pattern = re.compile(r"\b" + re.escape(identifier) + r"\b") + current_index = 0 + with file_path.open(encoding="utf-8") as f: + for line_idx, line in enumerate(f): + for match in pattern.finditer(line): + if current_index == occurrence_index: + return line_idx, match.start() + column_offset + current_index += 1 + return None diff --git a/test/diagnostics_cases.py b/test/diagnostics_cases.py new file mode 100644 index 00000000..8cfcfacb --- /dev/null +++ b/test/diagnostics_cases.py @@ -0,0 +1,230 @@ +import os +from dataclasses import dataclass +from typing import cast + +import pytest +from _pytest.mark import Mark, MarkDecorator + +from solidlsp.ls_config import Language +from test.conftest import get_pytest_markers + + +@dataclass(frozen=True) +class DiagnosticCase: + language: Language + relative_path: str + primary_symbol_name_path: str + primary_symbol_identifier: str + reference_symbol_name_path: str + reference_symbol_identifier: str + primary_message_fragment: str + reference_message_fragment: str + + +def diagnostic_case_param( + case: DiagnosticCase, + *marks: MarkDecorator | Mark, + id: str, +): + return pytest.param(case.language, case, marks=[*get_pytest_markers(case.language), *marks], id=id) + + +DIAGNOSTIC_CASE_PARAMS = [ + diagnostic_case_param( + DiagnosticCase( + language=Language.PYTHON, + relative_path=os.path.join("test_repo", "diagnostics_sample.py"), + primary_symbol_name_path="broken_factory", + primary_symbol_identifier="broken_factory", + reference_symbol_name_path="broken_consumer", + reference_symbol_identifier="broken_consumer", + primary_message_fragment="missing_user", + reference_message_fragment="undefined_name", + ), + id="python_missing_user", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.PYTHON_TY, + relative_path=os.path.join("test_repo", "diagnostics_sample.py"), + primary_symbol_name_path="broken_factory", + primary_symbol_identifier="broken_factory", + reference_symbol_name_path="broken_consumer", + reference_symbol_identifier="broken_consumer", + primary_message_fragment="missing_user", + reference_message_fragment="undefined_name", + ), + id="python_ty_missing_user", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.GO, + relative_path="diagnostics_sample.go", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + id="go_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.JAVA, + relative_path=os.path.join("src", "main", "java", "test_repo", "DiagnosticsSample.java"), + primary_symbol_name_path="DiagnosticsSample/brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="DiagnosticsSample/brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + id="java_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.KOTLIN, + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "DiagnosticsSample.kt"), + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + id="kotlin_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.RUST, + relative_path=os.path.join("src", "diagnostics_sample.rs"), + primary_symbol_name_path="broken_factory", + primary_symbol_identifier="broken_factory", + reference_symbol_name_path="broken_consumer", + reference_symbol_identifier="broken_consumer", + primary_message_fragment="missing_greeting", + reference_message_fragment="missing_consumer_value", + ), + pytest.mark.xfail(reason="rust-analyzer does not surface diagnostics for this fixture through Serena currently"), + id="rust_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.PHP, + relative_path="diagnostics_sample.php", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.xfail(reason="PHP LS integration does not expose document diagnostics in this environment"), + id="php_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.CLOJURE, + relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"), + primary_symbol_name_path="broken-factory", + primary_symbol_identifier="broken-factory", + reference_symbol_name_path="broken-consumer", + reference_symbol_identifier="broken-consumer", + primary_message_fragment="missing-greeting", + reference_message_fragment="missing-consumer-value", + ), + id="clojure_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.CSHARP, + relative_path="DiagnosticsSample.cs", + primary_symbol_name_path="TestProject/DiagnosticsSample/BrokenFactory", + primary_symbol_identifier="BrokenFactory", + reference_symbol_name_path="TestProject/DiagnosticsSample/BrokenConsumer", + reference_symbol_identifier="BrokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + id="csharp_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.POWERSHELL, + relative_path="diagnostics_sample.ps1", + primary_symbol_name_path="function Invoke-BrokenFactory ()", + primary_symbol_identifier="Invoke-BrokenFactory", + reference_symbol_name_path="function Invoke-BrokenConsumer ()", + reference_symbol_identifier="Invoke-BrokenConsumer", + primary_message_fragment="MissingGreeting", + reference_message_fragment="MissingConsumerValue", + ), + pytest.mark.xfail(reason="PowerShell LS does not surface document diagnostics in this environment"), + id="powershell_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.CPP_CCLS, + relative_path="diagnostics_sample.cpp", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.xfail(reason="ccls does not expose document diagnostics through this integration"), + id="cpp_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.LEAN4, + relative_path="DiagnosticsSample.lean", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.xfail(reason="Lean4 LS does not reliably surface diagnostics in CI"), + id="lean_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.TYPESCRIPT, + relative_path="diagnostics_sample.ts", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.typescript, + pytest.mark.xfail(reason="TypeScript LS does not surface document diagnostics through this integration"), + id="typescript_missing_greeting", + ), + diagnostic_case_param( + DiagnosticCase( + language=Language.FSHARP, + relative_path="DiagnosticsSample.fs", + primary_symbol_name_path="brokenFactory", + primary_symbol_identifier="brokenFactory", + reference_symbol_name_path="brokenConsumer", + reference_symbol_identifier="brokenConsumer", + primary_message_fragment="missingGreeting", + reference_message_fragment="missingConsumerValue", + ), + pytest.mark.xfail(reason="F# LS does not expose document diagnostics through this integration"), + id="fsharp_missing_greeting", + ), +] + +WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS = [ + case_param + for case_param in DIAGNOSTIC_CASE_PARAMS + if cast(DiagnosticCase, case_param.values[1]).language + in {Language.PYTHON, Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, Language.CSHARP} +] diff --git a/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj new file mode 100644 index 00000000..ecc0c79c --- /dev/null +++ b/test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj @@ -0,0 +1,9 @@ +(ns test-app.diagnostics-sample) + +(defn broken-factory [] + missing-greeting) + +(defn broken-consumer [] + (let [value (broken-factory)] + (println value) + missing-consumer-value)) diff --git a/test/resources/repos/cpp/test_repo/compile_commands.json b/test/resources/repos/cpp/test_repo/compile_commands.json index 21c02f62..cb096a60 100644 --- a/test/resources/repos/cpp/test_repo/compile_commands.json +++ b/test/resources/repos/cpp/test_repo/compile_commands.json @@ -8,5 +8,10 @@ "directory": ".", "command": "g++ -std=c++17 -I . -c b.cpp", "file": "b.cpp" + }, + { + "directory": ".", + "command": "g++ -std=c++17 -I . -c diagnostics_sample.cpp", + "file": "diagnostics_sample.cpp" } -] \ No newline at end of file +] diff --git a/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp new file mode 100644 index 00000000..78075fc8 --- /dev/null +++ b/test/resources/repos/cpp/test_repo/diagnostics_sample.cpp @@ -0,0 +1,8 @@ +int brokenFactory() { + return missingGreeting; +} + +int brokenConsumer() { + int value = brokenFactory(); + return value + missingConsumerValue; +} diff --git a/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs new file mode 100644 index 00000000..1356e26d --- /dev/null +++ b/test/resources/repos/csharp/test_repo/DiagnosticsSample.cs @@ -0,0 +1,19 @@ +using System; + +namespace TestProject +{ + public static class DiagnosticsSample + { + public static string BrokenFactory() + { + return missingGreeting; + } + + public static void BrokenConsumer() + { + string value = BrokenFactory(); + Console.WriteLine(value); + Console.WriteLine(missingConsumerValue); + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Program.cs b/test/resources/repos/csharp/test_repo/Program.cs index 55c8d228..4bcf0f6a 100644 --- a/test/resources/repos/csharp/test_repo/Program.cs +++ b/test/resources/repos/csharp/test_repo/Program.cs @@ -1,4 +1,5 @@ using System; +using TestProject.Services; namespace TestProject { @@ -7,10 +8,13 @@ namespace TestProject static void Main(string[] args) { Console.WriteLine("Hello, World!"); - + var calculator = new Calculator(); int result = calculator.Add(5, 3); Console.WriteLine($"5 + 3 = {result}"); + + IGreeter greeter = new ConsoleGreeter(); + Console.WriteLine(greeter.FormatGreeting("World")); } } @@ -40,4 +44,4 @@ namespace TestProject return (double)a / b; } } -} \ No newline at end of file +} diff --git a/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs new file mode 100644 index 00000000..d80d8ba8 --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Services/ConsoleGreeter.cs @@ -0,0 +1,10 @@ +namespace TestProject.Services +{ + public class ConsoleGreeter : IGreeter + { + public string FormatGreeting(string name) + { + return $"Hello, {name}!"; + } + } +} diff --git a/test/resources/repos/csharp/test_repo/Services/IGreeter.cs b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs new file mode 100644 index 00000000..2f64ca0e --- /dev/null +++ b/test/resources/repos/csharp/test_repo/Services/IGreeter.cs @@ -0,0 +1,7 @@ +namespace TestProject.Services +{ + public interface IGreeter + { + string FormatGreeting(string name); + } +} diff --git a/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs new file mode 100644 index 00000000..815b844b --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/DiagnosticsSample.fs @@ -0,0 +1,11 @@ +module DiagnosticsSample + +let brokenConsumerValue = 1 + +let brokenFactory () = + missingGreeting + +let brokenConsumer () = + let value = brokenFactory () + printfn "%A" value + missingConsumerValue diff --git a/test/resources/repos/fsharp/test_repo/Formatter.fs b/test/resources/repos/fsharp/test_repo/Formatter.fs new file mode 100644 index 00000000..52085d3f --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Formatter.fs @@ -0,0 +1,8 @@ +module Formatter + +type IGreeter = + abstract member FormatGreeting: string -> string + +type ConsoleGreeter() = + interface IGreeter with + member _.FormatGreeting(name: string) = sprintf "Hello, %s!" name diff --git a/test/resources/repos/fsharp/test_repo/Program.fs b/test/resources/repos/fsharp/test_repo/Program.fs index e5328ceb..3305598c 100644 --- a/test/resources/repos/fsharp/test_repo/Program.fs +++ b/test/resources/repos/fsharp/test_repo/Program.fs @@ -1,7 +1,9 @@ module Program open Calculator +open Formatter open Models +open Shapes [] let main argv = @@ -24,6 +26,12 @@ let main argv = let calc = CalculatorClass() let classResult = calc.Add(20, 5) printfn "Calculator class: 20 + 5 = %d" classResult + + let greeter: IGreeter = ConsoleGreeter() :> IGreeter + printfn "%s" (greeter.FormatGreeting("World")) + + let shape: Shape = Circle(2.0) :> Shape + printfn "Area: %.2f" (shape.Area()) // Test person module let person = PersonModule.createPerson "Alice Smith" 25 (Some "alice@example.com") @@ -34,4 +42,4 @@ let main argv = let fact5 = factorial 5 printfn "5! = %d" fact5 - 0 // return success \ No newline at end of file + 0 // return success diff --git a/test/resources/repos/fsharp/test_repo/Shapes.fs b/test/resources/repos/fsharp/test_repo/Shapes.fs new file mode 100644 index 00000000..d67a98aa --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Shapes.fs @@ -0,0 +1,10 @@ +module Shapes + +[] +type Shape() = + abstract member Area: unit -> float + +type Circle(radius: float) = + inherit Shape() + + override _.Area() = System.Math.PI * radius * radius diff --git a/test/resources/repos/fsharp/test_repo/TestProject.fsproj b/test/resources/repos/fsharp/test_repo/TestProject.fsproj index 39b621ff..e44baee0 100644 --- a/test/resources/repos/fsharp/test_repo/TestProject.fsproj +++ b/test/resources/repos/fsharp/test_repo/TestProject.fsproj @@ -7,8 +7,11 @@ + + + - \ No newline at end of file + diff --git a/test/resources/repos/go/test_repo/diagnostics_sample.go b/test/resources/repos/go/test_repo/diagnostics_sample.go new file mode 100644 index 00000000..15f7d6a2 --- /dev/null +++ b/test/resources/repos/go/test_repo/diagnostics_sample.go @@ -0,0 +1,11 @@ +package main + +func brokenFactory() string { + return missingGreeting +} + +func brokenConsumer() { + value := brokenFactory() + _ = value + _ = missingConsumerValue +} diff --git a/test/resources/repos/go/test_repo/main.go b/test/resources/repos/go/test_repo/main.go index a5716358..44412892 100644 --- a/test/resources/repos/go/test_repo/main.go +++ b/test/resources/repos/go/test_repo/main.go @@ -5,6 +5,8 @@ import "fmt" func main() { fmt.Println("Hello, Go!") Helper() + var greeter Greeter = ConsoleGreeter{} + fmt.Println(greeter.FormatGreeting("Go")) } func Helper() { @@ -22,3 +24,13 @@ func (d *DemoStruct) Value() int { func UsingHelper() { Helper() } + +type Greeter interface { + FormatGreeting(name string) string +} + +type ConsoleGreeter struct{} + +func (ConsoleGreeter) FormatGreeting(name string) string { + return "Hello, " + name + "!" +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java new file mode 100644 index 00000000..e8b85c99 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/ConsoleGreeter.java @@ -0,0 +1,8 @@ +package test_repo; + +public class ConsoleGreeter implements Greeter { + @Override + public String formatGreeting(String name) { + return "Hello, " + name + "!"; + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java new file mode 100644 index 00000000..53f8406b --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/DiagnosticsSample.java @@ -0,0 +1,13 @@ +package test_repo; + +public class DiagnosticsSample { + public static String brokenFactory() { + return missingGreeting; + } + + public static void brokenConsumer() { + String value = brokenFactory(); + System.out.println(value); + System.out.println(missingConsumerValue); + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java new file mode 100644 index 00000000..569d5006 --- /dev/null +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Greeter.java @@ -0,0 +1,5 @@ +package test_repo; + +public interface Greeter { + String formatGreeting(String name); +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java index ed79b9fe..29c0688e 100644 --- a/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Main.java @@ -6,6 +6,8 @@ public class Main { Model model = new Model("Cascade"); System.out.println(model.getName()); acceptModel(model); + Greeter greeter = new ConsoleGreeter(); + System.out.println(greeter.formatGreeting("Cascade")); } public static void acceptModel(Model m) { // Do nothing, just for LSP reference diff --git a/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2a84e188 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/test/resources/repos/kotlin/test_repo/gradlew b/test/resources/repos/kotlin/test_repo/gradlew new file mode 100644 index 00000000..ef07e016 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/test/resources/repos/kotlin/test_repo/gradlew.bat b/test/resources/repos/kotlin/test_repo/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt new file mode 100644 index 00000000..93f0b939 --- /dev/null +++ b/test/resources/repos/kotlin/test_repo/src/main/kotlin/test_repo/DiagnosticsSample.kt @@ -0,0 +1,11 @@ +package test_repo + +fun brokenFactory(): String { + return missingGreeting +} + +fun brokenConsumer() { + val value = brokenFactory() + println(value) + println(missingConsumerValue) +} diff --git a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean new file mode 100644 index 00000000..a2c570ec --- /dev/null +++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean @@ -0,0 +1,6 @@ +def brokenFactory : Nat := + missingGreeting + +def brokenConsumer : Nat := + let value := brokenFactory + value + missingConsumerValue diff --git a/test/resources/repos/lua/test_repo/main.lua b/test/resources/repos/lua/test_repo/main.lua index d19d5212..cd6ecc97 100644 --- a/test/resources/repos/lua/test_repo/main.lua +++ b/test/resources/repos/lua/test_repo/main.lua @@ -3,6 +3,7 @@ -- main.lua: Entry point for the test application local calculator = require("src.calculator") +local animals = require("src.animals") local utils = require("src.utils") local function print_banner() @@ -50,6 +51,12 @@ local function test_utils() logger:warn("This is a warning") end +local function test_animals() + print("\nTesting Animal Module:") + local dog = animals.Dog:new("Rex") + print(dog:speak()) +end + local function interactive_calculator() print("\nInteractive Calculator (type 'quit' to exit):") while true do @@ -99,11 +106,13 @@ local function main(args) if #args == 0 then test_calculator() test_utils() + test_animals() elseif args[1] == "interactive" then interactive_calculator() elseif args[1] == "test" then test_calculator() test_utils() + test_animals() print("\nAll tests completed!") else print("Usage: lua main.lua [interactive|test]") @@ -111,4 +120,4 @@ local function main(args) end -- Run main function -main(arg or {}) \ No newline at end of file +main(arg or {}) diff --git a/test/resources/repos/lua/test_repo/src/animals.lua b/test/resources/repos/lua/test_repo/src/animals.lua new file mode 100644 index 00000000..92f08f26 --- /dev/null +++ b/test/resources/repos/lua/test_repo/src/animals.lua @@ -0,0 +1,37 @@ +---@class Animal +local Animal = {} +Animal.__index = Animal + +---@param name string +---@return Animal +function Animal:new(name) + local self = setmetatable({}, Animal) + self.name = name + return self +end + +---@return string +function Animal:speak() + return self.name .. " makes a sound" +end + +---@class Dog: Animal +local Dog = setmetatable({}, { __index = Animal }) +Dog.__index = Dog + +---@param name string +---@return Dog +function Dog:new(name) + local self = Animal.new(self, name) + return setmetatable(self, Dog) +end + +---@return string +function Dog:speak() + return self.name .. " says woof" +end + +return { + Animal = Animal, + Dog = Dog, +} diff --git a/test/resources/repos/php/test_repo/diagnostics_sample.php b/test/resources/repos/php/test_repo/diagnostics_sample.php new file mode 100644 index 00000000..b1a6be19 --- /dev/null +++ b/test/resources/repos/php/test_repo/diagnostics_sample.php @@ -0,0 +1,11 @@ + User: + return missing_user + + +def broken_consumer() -> None: + created_user = broken_factory() + print(created_user) + print(undefined_name) diff --git a/test/resources/repos/ruby/test_repo/lib.rb b/test/resources/repos/ruby/test_repo/lib.rb index 5dd8504b..65eba9de 100644 --- a/test/resources/repos/ruby/test_repo/lib.rb +++ b/test/resources/repos/ruby/test_repo/lib.rb @@ -7,3 +7,15 @@ class Calculator a - b end end + +class Greeter + def format_greeting(name) + name + end +end + +class ConsoleGreeter < Greeter + def format_greeting(name) + "Hello, #{name}!" + end +end diff --git a/test/resources/repos/ruby/test_repo/main.rb b/test/resources/repos/ruby/test_repo/main.rb index dcf1b0f2..b9a98c92 100644 --- a/test/resources/repos/ruby/test_repo/main.rb +++ b/test/resources/repos/ruby/test_repo/main.rb @@ -15,6 +15,8 @@ end def helper_function(number = 42) demo = DemoClass.new(number) Calculator.new.add(demo.value, 10) + greeter = ConsoleGreeter.new + puts greeter.format_greeting("Ruby") demo.print_value end diff --git a/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs new file mode 100644 index 00000000..120e9e36 --- /dev/null +++ b/test/resources/repos/rust/test_repo/src/diagnostics_sample.rs @@ -0,0 +1,9 @@ +pub fn broken_factory() -> String { + missing_greeting +} + +pub fn broken_consumer() { + let value = broken_factory(); + println!("{value}"); + println!("{}", missing_consumer_value); +} diff --git a/test/resources/repos/rust/test_repo/src/lib.rs b/test/resources/repos/rust/test_repo/src/lib.rs index 8ad43ead..178d0165 100644 --- a/test/resources/repos/rust/test_repo/src/lib.rs +++ b/test/resources/repos/rust/test_repo/src/lib.rs @@ -1,3 +1,5 @@ +pub mod diagnostics_sample; + // This function returns the sum of 2 + 2 pub fn add() -> i32 { let res = 2 + 2; @@ -7,3 +9,15 @@ pub fn multiply() -> i32 { 2 * 3 } +pub trait Greeter { + fn format_greeting(&self, name: &str) -> String; +} + +pub struct ConsoleGreeter; + +impl Greeter for ConsoleGreeter { + fn format_greeting(&self, name: &str) -> String { + format!("Hello, {name}!") + } +} + diff --git a/test/resources/repos/rust/test_repo/src/main.rs b/test/resources/repos/rust/test_repo/src/main.rs index 7385c9d9..a5bb8063 100644 --- a/test/resources/repos/rust/test_repo/src/main.rs +++ b/test/resources/repos/rust/test_repo/src/main.rs @@ -1,8 +1,10 @@ -use rsandbox::add; +use rsandbox::{add, ConsoleGreeter, Greeter}; fn main() { println!("Hello, World!"); println!("Good morning!"); println!("add result: {}", add()); + let greeter = ConsoleGreeter; + println!("{}", greeter.format_greeting("Rust")); println!("inserted line"); } diff --git a/test/resources/repos/typescript/test_repo/.serena/project.yml b/test/resources/repos/typescript/test_repo/.serena/project.yml index b9f472b6..942b95a1 100644 --- a/test/resources/repos/typescript/test_repo/.serena/project.yml +++ b/test/resources/repos/typescript/test_repo/.serena/project.yml @@ -1,7 +1,153 @@ + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. excluded_tools: [] + +# whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. ignored_paths: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). initial_prompt: '' -language: typescript +# the name by which the project can be referenced within Serena project_name: test_repo + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 read_only: false + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# 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. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +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. +ls_specific_settings: {} + +# 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 +encoding: utf-8 + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi python_ty +# r +# rego ruby ruby_solargraph rust scala +# swift 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 Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript diff --git a/test/resources/repos/typescript/test_repo/diagnostics_sample.ts b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts new file mode 100644 index 00000000..566643e1 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/diagnostics_sample.ts @@ -0,0 +1,9 @@ +export function brokenFactory(): string { + return missingGreeting; +} + +export function brokenConsumer(): void { + const value = brokenFactory(); + console.log(value); + console.log(missingConsumerValue); +} diff --git a/test/resources/repos/typescript/test_repo/formatters.ts b/test/resources/repos/typescript/test_repo/formatters.ts new file mode 100644 index 00000000..fe233f93 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/formatters.ts @@ -0,0 +1,9 @@ +export interface Greeter { + formatGreeting(name: string): string; +} + +export class ConsoleGreeter implements Greeter { + formatGreeting(name: string): string { + return `Hello, ${name}!`; + } +} diff --git a/test/resources/repos/typescript/test_repo/index.ts b/test/resources/repos/typescript/test_repo/index.ts index 724a67c6..417e5863 100644 --- a/test/resources/repos/typescript/test_repo/index.ts +++ b/test/resources/repos/typescript/test_repo/index.ts @@ -1,3 +1,5 @@ +import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -11,6 +13,9 @@ export class DemoClass { export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index 6c688345..8fd3a73b 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -67,11 +67,16 @@ # --- # name: test_delete_symbol[test_case1] ''' - + import { ConsoleGreeter, Greeter } from "./formatters"; + + export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -515,6 +520,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case1-after] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -532,6 +539,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -544,6 +554,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case1-before] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + function newFunctionAfterClass(): void { console.log("This function is after DemoClass."); } @@ -561,6 +573,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -573,6 +588,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case2-after] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -586,6 +603,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } function newInsertedFunction(): void { @@ -602,6 +622,8 @@ # --- # name: test_insert_in_rel_to_symbol[test_case2-before] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -619,6 +641,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); @@ -1686,6 +1711,8 @@ # --- # name: test_replace_body[test_case1] ''' + import { ConsoleGreeter, Greeter } from "./formatters"; + export class DemoClass { value: number; constructor(value: number) { @@ -1700,6 +1727,9 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); + + const greeter: Greeter = new ConsoleGreeter(); + console.log(greeter.formatGreeting("World")); } helperFunction(); diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 896b030f..62dc1a53 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -2,12 +2,15 @@ import json import logging import os import re +import shutil import time from collections.abc import Iterator from contextlib import contextmanager from typing import Literal import pytest +from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase +from _pytest.mark import Mark, MarkDecorator from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig @@ -15,8 +18,13 @@ from serena.project import Project from serena.tools import ( SUCCESS_RESULT, ActivateProjectTool, + FindDefiningSymbolAtLocationTool, + FindDefiningSymbolTool, + FindImplementationsTool, FindReferencingSymbolsTool, FindSymbolTool, + GetDiagnosticsForFileTool, + GetDiagnosticsForSymbolTool, InitialInstructionsTool, ReplaceContentTool, ReplaceSymbolBodyTool, @@ -25,9 +33,208 @@ from serena.tools import ( ) from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind -from test.conftest import get_repo_path, is_ci, language_tests_enabled +from test.conftest import ( + find_identifier_occurrence_position, + get_repo_path, + is_ci, + language_has_verified_implementation_support, + language_tests_enabled, +) +from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase from test.solidlsp import clojure as clj +DEFINING_SYMBOL_TOOL_TEST_CASES = [ + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + "User", + 1, + 1, + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON_TY, + os.path.join("test_repo", "services.py"), + "User", + 1, + 1, + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go), + pytest.param( + Language.JAVA, + os.path.join("src", "main", "java", "test_repo", "Main.java"), + "Model", + 0, + 1, + "Model", + "Model.java", + marks=pytest.mark.java, + ), + pytest.param( + Language.KOTLIN, + os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + "Model", + 0, + 1, + "Model", + "Model.kt", + marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + pytest.param( + Language.RUST, + os.path.join("src", "main.rs"), + "format_greeting", + 0, + 1, + "format_greeting", + "lib.rs", + marks=pytest.mark.rust, + ), + pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php), + pytest.param( + Language.CLOJURE, + clj.UTILS_PATH, + "multiply", + 0, + 1, + "multiply", + clj.CORE_PATH, + marks=[ + pytest.mark.clojure, + pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"), + ], + ), + pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp), + pytest.param( + Language.POWERSHELL, + "main.ps1", + "Convert-ToUpperCase", + 0, + 1, + "function Convert-ToUpperCase ()", + "utils.ps1", + marks=pytest.mark.powershell, + ), + pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp), + pytest.param( + Language.LEAN4, + "Main.lean", + "add", + 0, + 1, + "add", + "Helper.lean", + marks=[ + pytest.mark.lean4, + pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"), + ], + ), + pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript), + pytest.param( + Language.FSHARP, + "Program.fs", + "add", + 0, + 1, + "add", + "Calculator.fs", + marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")], + ), +] + +REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES = [ + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + r"from \.models import Item, (User)", + "", + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + r"=\s+(User)\(", + "UserService/create_user", + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON_TY, + os.path.join("test_repo", "services.py"), + r"=\s+(User)\(", + "UserService/create_user", + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.GO, + "main.go", + r"var greeter (Greeter) =", + "main", + "Greeter", + "main.go", + marks=pytest.mark.go, + ), +] + +IMPLEMENTATION_TOOL_TEST_CASE_DATA = [ + ( + Language.CSHARP, + "IGreeter/FormatGreeting", + os.path.join("Services", "IGreeter.cs"), + os.path.join("Services", "ConsoleGreeter.cs"), + "FormatGreeting", + pytest.mark.csharp, + ), + ( + Language.GO, + "Greeter/FormatGreeting", + "main.go", + "main.go", + "(ConsoleGreeter).FormatGreeting", + pytest.mark.go, + ), + ( + Language.JAVA, + "Greeter/formatGreeting", + os.path.join("src", "main", "java", "test_repo", "Greeter.java"), + os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"), + "formatGreeting", + pytest.mark.java, + ), + ( + Language.RUST, + "Greeter/format_greeting", + os.path.join("src", "lib.rs"), + os.path.join("src", "lib.rs"), + "format_greeting", + pytest.mark.rust, + ), + ( + Language.TYPESCRIPT, + "Greeter/formatGreeting", + "formatters.ts", + "formatters.ts", + "formatGreeting", + pytest.mark.typescript, + ), +] + +IMPLEMENTATION_TOOL_TEST_CASES = [ + pytest.param(language, symbol_name, def_file, impl_file, expected_symbol_name, marks=mark) + for language, symbol_name, def_file, impl_file, expected_symbol_name, mark in IMPLEMENTATION_TOOL_TEST_CASE_DATA + if language_has_verified_implementation_support(language) +] + @pytest.fixture def serena_config(): @@ -37,6 +244,7 @@ def serena_config(): test_projects = [] for language in [ Language.PYTHON, + Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, @@ -82,6 +290,13 @@ def read_project_file(project: Project, relative_path: str) -> str: return f.read() +def parse_edit_diagnostics_result(result: str) -> dict: + """Utility function to parse the diagnostic payload returned by edit tools.""" + prefix = "Edit introduced new warning-or-higher diagnostics: " + assert result.startswith(prefix), result + return json.loads(result[len(prefix) :]) + + @contextmanager def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]: """Context manager to modify a project file and revert the changes after use.""" @@ -267,6 +482,174 @@ class TestSerenaAgent: refs = json.loads(result) assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" + def _assert_find_symbol_implementations( + self, + serena_agent: SerenaAgent, + symbol_name: str, + def_file: str, + impl_file: str, + expected_symbol_name: str, + ) -> None: + agent = serena_agent + + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) + symbols = json.loads(result) + assert symbols, f"Expected to find symbol {symbol_name} in {def_file}" + + def_symbol = symbols[0] + find_impl_tool = agent.get_tool(FindImplementationsTool) + result = find_impl_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"], include_info=True) + implementations = json.loads(result) + + assert any( + impl_file in implementation["relative_path"] + and ( + implementation.get("name") == expected_symbol_name + or implementation.get("name_path") == expected_symbol_name + or expected_symbol_name in implementation.get("info", "") + ) + for implementation in implementations + ), f"Expected to find implementation of {symbol_name} in {impl_file}. implementations={implementations}" + + for implementation in implementations: + if implementation["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): + continue + symbol_info = implementation.get("info") + assert symbol_info, f"Expected symbol info to be present for implementation: {implementation}" + + def _assert_find_defining_symbol( + self, + serena_agent: SerenaAgent, + relative_path: str, + identifier: str, + occurrence_index: int, + column_offset: int, + expected_name: str, + expected_definition_file: str, + ) -> None: + project_root = get_repo_path(serena_agent.get_active_lsp_languages()[0]) + position = find_identifier_occurrence_position(project_root / relative_path, identifier, occurrence_index, column_offset) + assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}" + + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool) + result = find_defining_symbol_tool.apply(relative_path=relative_path, line=position[0], column=position[1], include_info=True) + defining_symbol = json.loads(result) + + assert defining_symbol is not None, f"Expected defining symbol for {identifier!r} in {relative_path}" + assert defining_symbol.get("relative_path") is not None + assert expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" + ) + assert ( + defining_symbol.get("name") == expected_name + or defining_symbol.get("name_path") == expected_name + or expected_name in defining_symbol.get("info", "") + ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" + + if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: + return + if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name): + assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}" + + def _assert_find_defining_symbol_by_regex( + self, + serena_agent: SerenaAgent, + relative_path: str, + regex: str, + containing_symbol_name_path: str, + expected_name: str, + expected_definition_file: str, + ) -> None: + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) + result = find_defining_symbol_tool.apply( + regex=regex, + relative_path=relative_path, + containing_symbol_name_path=containing_symbol_name_path, + include_info=True, + ) + defining_symbol = json.loads(result) + + assert defining_symbol is not None, f"Expected defining symbol for regex {regex!r} in {relative_path}" + assert defining_symbol.get("relative_path") is not None + assert expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" + ) + assert ( + defining_symbol.get("name") == expected_name + or defining_symbol.get("name_path") == expected_name + or expected_name in defining_symbol.get("info", "") + ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" + + if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: + return + if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name): + assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}" + + def _assert_diagnostics_for_file( + self, + serena_agent: SerenaAgent, + diagnostic_case: DiagnosticCase, + start_line: int = 0, + end_line: int = -1, + ) -> None: + diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool) + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + start_line=start_line, + end_line=end_line, + min_severity=1, + ) + grouped_diagnostics = json.loads(result) + + assert diagnostic_case.relative_path in grouped_diagnostics, grouped_diagnostics + severity_group = grouped_diagnostics[diagnostic_case.relative_path] + assert "Error" in severity_group, severity_group + name_path_group = severity_group["Error"] + + for expected_name_path in [diagnostic_case.primary_symbol_name_path, diagnostic_case.reference_symbol_name_path]: + assert expected_name_path in name_path_group, name_path_group + + diagnostic_messages = [ + diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path + ] + for expected_fragment in [diagnostic_case.primary_message_fragment, diagnostic_case.reference_message_fragment]: + assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages + + def _assert_diagnostics_for_symbol( + self, + serena_agent: SerenaAgent, + diagnostic_case: DiagnosticCase, + check_symbol_references: bool, + ) -> None: + diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForSymbolTool) + result = diagnostics_tool.apply( + name_path=diagnostic_case.primary_symbol_name_path, + reference_file=diagnostic_case.relative_path, + check_symbol_references=check_symbol_references, + min_severity=1, + ) + grouped_diagnostics = json.loads(result) + diagnostics_file = diagnostic_case.relative_path + + assert diagnostics_file in grouped_diagnostics, grouped_diagnostics + severity_group = grouped_diagnostics[diagnostics_file] + assert "Error" in severity_group, severity_group + name_path_group = severity_group["Error"] + + expected_name_paths = [diagnostic_case.primary_symbol_name_path] + expected_message_fragments = [diagnostic_case.primary_message_fragment] + if check_symbol_references: + expected_name_paths.append(diagnostic_case.reference_symbol_name_path) + expected_message_fragments.append(diagnostic_case.reference_message_fragment) + + assert set(expected_name_paths).issubset(name_path_group.keys()), name_path_group + diagnostic_messages = [ + diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path + ] + for expected_fragment in expected_message_fragments: + assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages + @pytest.mark.parametrize( "serena_agent,symbol_name,def_file,ref_file", [ @@ -341,6 +724,161 @@ class TestSerenaAgent: def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + @pytest.mark.parametrize( + "serena_agent,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file", + DEFINING_SYMBOL_TOOL_TEST_CASES, + indirect=["serena_agent"], + ) + def test_find_defining_symbol( + self, + serena_agent: SerenaAgent, + relative_path: str, + identifier: str, + occurrence_index: int, + column_offset: int, + expected_name: str, + expected_definition_file: str, + ) -> None: + self._assert_find_defining_symbol( + serena_agent, + relative_path, + identifier, + occurrence_index, + column_offset, + expected_name, + expected_definition_file, + ) + + @pytest.mark.parametrize( + "serena_agent,relative_path,regex,containing_symbol_name_path,expected_name,expected_definition_file", + REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES, + indirect=["serena_agent"], + ) + def test_find_defining_symbol_by_regex( + self, + serena_agent: SerenaAgent, + relative_path: str, + regex: str, + containing_symbol_name_path: str, + expected_name: str, + expected_definition_file: str, + ) -> None: + self._assert_find_defining_symbol_by_regex( + serena_agent, + relative_path, + regex, + containing_symbol_name_path, + expected_name, + expected_definition_file, + ) + + @pytest.mark.parametrize( + "serena_agent,relative_path,regex,containing_symbol_name_path,error_fragment", + [ + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + r"(User)", + "", + "Expected exactly one regex match", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + r"User", + "UserService/create_user", + "must contain exactly one capturing group", + marks=pytest.mark.python, + ), + ], + indirect=["serena_agent"], + ) + def test_find_defining_symbol_by_regex_error( + self, + serena_agent: SerenaAgent, + relative_path: str, + regex: str, + containing_symbol_name_path: str, + error_fragment: str, + ) -> None: + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) + result = find_defining_symbol_tool.apply( + regex=regex, + relative_path=relative_path, + containing_symbol_name_path=containing_symbol_name_path, + ) + assert result.startswith("Error: "), result + assert error_fragment in result, result + + @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) + def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + self._assert_diagnostics_for_file( + serena_agent, + diagnostic_case=diagnostic_case, + ) + + @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) + def test_get_diagnostics_for_file_in_range(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + project_root = get_repo_path(diagnostic_case.language) + primary_position = find_identifier_occurrence_position( + project_root / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier + ) + reference_position = find_identifier_occurrence_position( + project_root / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier + ) + assert primary_position is not None + assert reference_position is not None + + self._assert_diagnostics_for_file( + serena_agent, + diagnostic_case=DiagnosticCase( + language=diagnostic_case.language, + relative_path=diagnostic_case.relative_path, + primary_symbol_name_path=diagnostic_case.primary_symbol_name_path, + primary_symbol_identifier=diagnostic_case.primary_symbol_identifier, + reference_symbol_name_path=diagnostic_case.primary_symbol_name_path, + reference_symbol_identifier=diagnostic_case.reference_symbol_identifier, + primary_message_fragment=diagnostic_case.primary_message_fragment, + reference_message_fragment=diagnostic_case.primary_message_fragment, + ), + start_line=primary_position[0], + end_line=reference_position[0] - 1, + ) + + @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) + def test_get_diagnostics_for_symbol(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + self._assert_diagnostics_for_symbol( + serena_agent, + diagnostic_case=diagnostic_case, + check_symbol_references=False, + ) + + @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) + def test_get_diagnostics_for_symbol_with_references(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + self._assert_diagnostics_for_symbol( + serena_agent, + diagnostic_case=diagnostic_case, + check_symbol_references=True, + ) + + if IMPLEMENTATION_TOOL_TEST_CASES: + + @pytest.mark.parametrize( + "serena_agent,symbol_name,def_file,impl_file,expected_symbol_name", + IMPLEMENTATION_TOOL_TEST_CASES, + indirect=["serena_agent"], + ) + def test_find_symbol_implementations( + self, + serena_agent: SerenaAgent, + symbol_name: str, + def_file: str, + impl_file: str, + expected_symbol_name: str, + ) -> None: + self._assert_find_symbol_implementations(serena_agent, symbol_name, def_file, impl_file, expected_symbol_name) + @pytest.mark.parametrize( "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", [ @@ -588,6 +1126,62 @@ 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=pytest.mark.python), + pytest.param(Language.PYTHON_TY, marks=pytest.mark.python), + ], + indirect=["serena_agent"], + ) + def test_replace_content_reports_new_diagnostics(self, serena_agent: SerenaAgent): + """Tests that file-level edits report newly introduced diagnostics.""" + relative_path = os.path.join("test_repo", "services.py") + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + relative_path=relative_path, + needle="return container", + repl="return missing_container", + mode="literal", + ) + + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param(Language.PYTHON, marks=pytest.mark.python), + pytest.param(Language.PYTHON_TY, marks=pytest.mark.python), + ], + indirect=["serena_agent"], + ) + def test_replace_symbol_body_reports_new_diagnostics(self, serena_agent: SerenaAgent): + """Tests that symbol-level edits report newly introduced diagnostics.""" + relative_path = os.path.join("test_repo", "services.py") + replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) + + with project_file_modification_context(serena_agent, relative_path): + result = replace_symbol_body_tool.apply( + name_path="create_service_container", + relative_path=relative_path, + body=""" +def create_service_container() -> dict[str, Any]: + return missing_container +""", + ) + + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + @pytest.mark.parametrize( "serena_agent", [ diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py index f7d9dad6..5e8e1c93 100644 --- a/test/solidlsp/clojure/__init__.py +++ b/test/solidlsp/clojure/__init__.py @@ -13,8 +13,8 @@ def _test_clojure_cli() -> bool: CLI_FAIL = _test_clojure_cli() TEST_APP_PATH = Path("src") / "test_app" -CORE_PATH = str(TEST_APP_PATH / "core.clj") -UTILS_PATH = str(TEST_APP_PATH / "utils.clj") +CORE_PATH = str(TEST_APP_PATH / "core.clj").replace("\\", "/") +UTILS_PATH = str(TEST_APP_PATH / "utils.clj").replace("\\", "/") def is_clojure_cli_available() -> bool: diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 91f67781..97e44e5e 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -18,6 +18,7 @@ from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import SymbolUtils from solidlsp.settings import SolidLSPSettings +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -176,6 +177,33 @@ class TestCSharpLanguageServer: assert "bool" in method_hover_text, f"Hover should include 'bool' return type, got: {method_hover_text}" assert "IsAdult" in method_hover_text, f"Hover should include 'IsAdult' method name, got: {method_hover_text}" + if language_has_verified_implementation_support(Language.CSHARP): + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.CSHARP) + pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("Services/IGreeter.cs", *pos) + assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting" + assert any("ConsoleGreeter.cs" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.CSHARP) + pos = find_identifier_position(repo_path / "Services" / "IGreeter.cs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("Services/IGreeter.cs", *pos) + assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "ConsoleGreeter.cs" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.csharp class TestCSharpSolutionProjectOpening: diff --git a/test/solidlsp/fortran/test_fortran_basic.py b/test/solidlsp/fortran/test_fortran_basic.py index e7a45235..9a73ece1 100644 --- a/test/solidlsp/fortran/test_fortran_basic.py +++ b/test/solidlsp/fortran/test_fortran_basic.py @@ -11,6 +11,7 @@ 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 find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols # Mark all tests in this module as fortran tests @@ -38,6 +39,33 @@ class TestFortranLanguageServer: # Verify subroutine symbol assert SymbolUtils.symbol_tree_contains_name(symbols, "print_result"), "print_result subroutine not found in symbol tree" + if language_has_verified_implementation_support(Language.FORTRAN): + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FORTRAN) + pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance") + assert pos is not None, "Could not find interface distance in geometry.f90" + + implementations = language_server.request_implementation("modules/geometry.f90", *pos) + assert implementations, "Expected implementations for geometry_types.distance" + implementation_files = {implementation.get("relativePath", "") for implementation in implementations} + assert implementation_files == {"modules/geometry.f90"}, f"Unexpected implementation locations: {implementations}" + assert len(implementations) >= 2, f"Expected module procedure implementations, got: {implementations}" + + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FORTRAN) + pos = find_identifier_position(repo_path / "modules" / "geometry.f90", "distance") + assert pos is not None, "Could not find interface distance in geometry.f90" + + implementing_symbols = language_server.request_implementing_symbols("modules/geometry.f90", *pos) + assert implementing_symbols, "Expected implementing symbols for geometry_types.distance" + implementing_symbol_names = {symbol.get("name") for symbol in implementing_symbols} + assert {"distance_2d", "distance_3d"}.issubset(implementing_symbol_names), ( + f"Expected distance_2d and distance_3d, got: {implementing_symbols}" + ) + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) def test_request_document_symbols(self, language_server: SolidLanguageServer) -> None: """Test that document symbols can be retrieved from Fortran files.""" diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index d0f52443..f58ec0b1 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -7,7 +7,7 @@ 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 find_identifier_position, get_repo_path, is_ci, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -39,6 +39,33 @@ class TestFSharpLanguageServer: symbol_names = [s.get("name") for s in symbols] assert "main" in symbol_names, "main function not found in Program.fs symbols" + if language_has_verified_implementation_support(Language.FSHARP): + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FSHARP) + pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("Formatter.fs", *pos) + assert implementations, "Expected at least one implementation of IGreeter.FormatGreeting" + assert any("Formatter.fs" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.FSHARP) + pos = find_identifier_position(repo_path / "Formatter.fs", "FormatGreeting") + assert pos is not None, "Could not find IGreeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("Formatter.fs", *pos) + assert implementing_symbols, "Expected implementing symbols for IGreeter.FormatGreeting" + assert any( + symbol.get("name") == "FormatGreeting" and "Formatter.fs" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_get_document_symbols_calculator(self, language_server: SolidLanguageServer) -> None: """Test getting document symbols from Calculator.fs file.""" diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py index 93fcd98e..02aa3b9b 100644 --- a/test/solidlsp/go/test_go_basic.py +++ b/test/solidlsp/go/test_go_basic.py @@ -7,6 +7,7 @@ from serena.symbol import LanguageServerSymbol 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, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -47,6 +48,33 @@ 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" + if language_has_verified_implementation_support(Language.GO): + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.GO) + pos = find_identifier_position(repo_path / "main.go", "FormatGreeting") + assert pos is not None, "Could not find Greeter.FormatGreeting in fixture" + + implementations = language_server.request_implementation("main.go", *pos) + assert implementations, "Expected at least one implementation of Greeter.FormatGreeting" + assert any("main.go" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.FormatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.GO) + pos = find_identifier_position(repo_path / "main.go", "FormatGreeting") + assert pos is not None, "Could not find Greeter.FormatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("main.go", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting" + assert any( + symbol.get("name") == "(ConsoleGreeter).FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: """Filter workspace symbols to exact name matches in the test repo.""" diff --git a/test/solidlsp/java/test_java_basic.py b/test/solidlsp/java/test_java_basic.py index 56b3bbf4..4c50c3c4 100644 --- a/test/solidlsp/java/test_java_basic.py +++ b/test/solidlsp/java/test_java_basic.py @@ -5,7 +5,12 @@ 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.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 pytestmark = [pytest.mark.java, pytest.mark.skipif(not language_tests_enabled(Language.JAVA), reason="Java tests disabled")] @@ -52,6 +57,33 @@ class TestJavaLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + if language_has_verified_implementation_support(Language.JAVA): + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.JAVA) + pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementations = language_server.request_implementation("src/main/java/test_repo/Greeter.java", *pos) + assert implementations, "Expected at least one implementation of Greeter.formatGreeting" + assert any("ConsoleGreeter.java" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.JAVA) + pos = find_identifier_position(repo_path / "src/main/java/test_repo/Greeter.java", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("src/main/java/test_repo/Greeter.java", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting" + assert any( + symbol.get("name") == "formatGreeting" and "ConsoleGreeter.java" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/lua/test_lua_basic.py b/test/solidlsp/lua/test_lua_basic.py index f140881b..5465fda1 100644 --- a/test/solidlsp/lua/test_lua_basic.py +++ b/test/solidlsp/lua/test_lua_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind +from test.conftest import find_identifier_position, get_repo_path, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -82,6 +83,33 @@ class TestLuaLanguageServer: # Check for Logger class/table assert "Logger" in all_symbols or any("Logger" in s for s in all_symbols), "Logger not found in symbols" + if language_has_verified_implementation_support(Language.LUA): + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.LUA) + pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak") + assert pos is not None, "Could not find Animal:speak in fixture" + + implementations = language_server.request_implementation("src/animals.lua", *pos) + assert implementations, "Expected at least one implementation of Animal:speak" + assert any("animals.lua" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected Dog:speak in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.LUA) + pos = find_identifier_position(repo_path / "src" / "animals.lua", "speak") + assert pos is not None, "Could not find Animal:speak in fixture" + + implementing_symbols = language_server.request_implementing_symbols("src/animals.lua", *pos) + assert implementing_symbols, "Expected implementing symbols for Animal:speak" + assert any( + symbol.get("name") == "speak" and "animals.lua" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected Dog:speak symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.LUA], indirect=True) def test_find_symbols_in_main(self, language_server: SolidLanguageServer) -> None: """Test finding functions in main.lua.""" @@ -133,7 +161,7 @@ class TestLuaLanguageServer: assert refs is not None assert isinstance(refs, list) - # add function appears in: main.lua (lines 16, 71), test_calculator.lua (lines 22, 23, 24) + # add function appears in: main.lua (lines 17, 78), test_calculator.lua (lines 22, 23, 24) # Note: The declaration itself may or may not be included as a reference assert len(refs) >= 5, f"Should find at least 5 references to calculator.add, found {len(refs)}" @@ -153,7 +181,7 @@ class TestLuaLanguageServer: # Check main.lua has usages assert "main.lua" in ref_files, "Should find add usages in main.lua" - assert 15 in ref_files["main.lua"] or 70 in ref_files["main.lua"], ( + assert 16 in ref_files["main.lua"] or 77 in ref_files["main.lua"], ( f"Should find add usage in main.lua, found at lines {ref_files.get('main.lua', [])}" ) @@ -189,7 +217,7 @@ class TestLuaLanguageServer: assert refs is not None assert isinstance(refs, list) - # trim function appears in: usage (line 32 in main.lua) + # trim function appears in: usage (line 33 in main.lua) # Note: The declaration itself may or may not be included as a reference assert len(refs) >= 1, f"Should find at least 1 reference to utils.trim, found {len(refs)}" @@ -209,8 +237,8 @@ class TestLuaLanguageServer: # Check main.lua has usage assert "main.lua" in ref_files, "Should find trim usage in main.lua" - assert 31 in ref_files["main.lua"], ( - f"Should find trim usage at line 32 (0-indexed: 31) in main.lua, found at lines {ref_files.get('main.lua', [])}" + assert 32 in ref_files["main.lua"], ( + f"Should find trim usage at line 33 (0-indexed: 32) in main.lua, found at lines {ref_files.get('main.lua', [])}" ) # Check for cross-file references from main.lua diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py index f57b37bd..f3d46f0f 100644 --- a/test/solidlsp/python/test_python_basic.py +++ b/test/solidlsp/python/test_python_basic.py @@ -12,6 +12,7 @@ import pytest from serena.project import Project from serena.util.text_utils import LineType from solidlsp import SolidLanguageServer +from test.conftest import PYTHON_LANGUAGE_BACKENDS from test.solidlsp.conftest import PYTHON_BACKEND_LANGUAGES, format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -79,6 +80,26 @@ class TestPythonLanguageServerBasics: references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert len(references) > 1, "Should get valid references for create_user (using selectionRange if present)" + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_request_text_document_diagnostics_with_filters(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("test_repo", "diagnostics_sample.py") + + diagnostics = language_server.request_text_document_diagnostics(file_path) + assert len(diagnostics) >= 2 + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + assert any("missing_user" in message for message in diagnostic_messages), diagnostic_messages + assert any("undefined_name" in message for message in diagnostic_messages), diagnostic_messages + + factory_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=3, end_line=5, min_severity=1) + factory_messages = [diagnostic["message"] for diagnostic in factory_diagnostics] + assert factory_messages, "Expected diagnostics in broken_factory range" + assert all("missing_user" in message for message in factory_messages), factory_messages + + consumer_diagnostics = language_server.request_text_document_diagnostics(file_path, start_line=7, end_line=10, min_severity=1) + consumer_messages = [diagnostic["message"] for diagnostic in consumer_diagnostics] + assert consumer_messages, "Expected diagnostics in broken_consumer range" + assert all("undefined_name" in message for message in consumer_messages), consumer_messages + class TestProjectBasics: @pytest.mark.parametrize("project", PYTHON_BACKEND_LANGUAGES, indirect=True) diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py index 814892d0..29fce938 100644 --- a/test/solidlsp/rust/test_rust_basic.py +++ b/test/solidlsp/rust/test_rust_basic.py @@ -5,6 +5,7 @@ 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, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -57,6 +58,33 @@ class TestRustLanguageServer: assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" + if language_has_verified_implementation_support(Language.RUST): + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.RUST) + pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting") + assert pos is not None, "Could not find Greeter.format_greeting in fixture" + + implementations = language_server.request_implementation(os.path.join("src", "lib.rs"), *pos) + assert implementations, "Expected at least one implementation of Greeter.format_greeting" + assert any("src/lib.rs" in implementation.get("relativePath", "").replace("\\", "/") for implementation in implementations), ( + f"Expected ConsoleGreeter.format_greeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.RUST) + pos = find_identifier_position(repo_path / os.path.join("src", "lib.rs"), "format_greeting") + assert pos is not None, "Could not find Greeter.format_greeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols(os.path.join("src", "lib.rs"), *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.format_greeting" + assert any( + symbol.get("name") == "format_greeting" and "src/lib.rs" in symbol["location"].get("relativePath", "").replace("\\", "/") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.format_greeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/solidity/test_solidity_basic.py b/test/solidlsp/solidity/test_solidity_basic.py index e095871f..2279140e 100644 --- a/test/solidlsp/solidity/test_solidity_basic.py +++ b/test/solidlsp/solidity/test_solidity_basic.py @@ -13,6 +13,7 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language +from test.conftest import language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -165,6 +166,33 @@ class TestSolidityLanguageServerBasics: ref_files = {ref.get("uri", "") for ref in references} assert any("Token.sol" in uri for uri in ref_files), "IERC20.transfer references should include Token.sol" + if language_has_verified_implementation_support(Language.SOLIDITY): + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + + implementations = language_server.request_implementation("contracts/interfaces/IERC20.sol", *pos) + assert implementations, "Expected Token.transfer to be returned as an implementation" + assert any("Token.sol" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected Token.transfer implementation, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.SOLIDITY], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + pos = _find_identifier_position(repo_path / "contracts/interfaces/IERC20.sol", "transfer") + assert pos is not None, "Should find 'transfer' identifier in IERC20.sol" + + implementing_symbols = language_server.request_implementing_symbols("contracts/interfaces/IERC20.sol", *pos) + assert implementing_symbols, "Expected implementing symbols for IERC20.transfer" + assert any( + symbol.get("name") == "transfer" and "Token.sol" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected Token.transfer symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) diff --git a/test/solidlsp/test_defining_symbol_matrix.py b/test/solidlsp/test_defining_symbol_matrix.py new file mode 100644 index 00000000..c0509a2f --- /dev/null +++ b/test/solidlsp/test_defining_symbol_matrix.py @@ -0,0 +1,139 @@ +import os +import shutil +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import find_identifier_occurrence_position, is_ci +from test.solidlsp import clojure as clj + + +@pytest.mark.parametrize( + "language_server,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file", + [ + pytest.param( + Language.PYTHON, + os.path.join("test_repo", "services.py"), + "User", + 1, + 1, + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param( + Language.PYTHON_TY, + os.path.join("test_repo", "services.py"), + "User", + 1, + 1, + "User", + "models.py", + marks=pytest.mark.python, + ), + pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go), + pytest.param( + Language.JAVA, + os.path.join("src", "main", "java", "test_repo", "Main.java"), + "Model", + 0, + 1, + "Model", + "Model.java", + marks=pytest.mark.java, + ), + pytest.param( + Language.KOTLIN, + os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + "Model", + 0, + 1, + "Model", + "Model.kt", + marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + pytest.param( + Language.RUST, + os.path.join("src", "main.rs"), + "format_greeting", + 0, + 1, + "format_greeting", + "lib.rs", + marks=pytest.mark.rust, + ), + pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php), + pytest.param( + Language.CLOJURE, + clj.UTILS_PATH, + "multiply", + 0, + 1, + "multiply", + clj.CORE_PATH, + marks=[ + pytest.mark.clojure, + pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"), + ], + ), + pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp), + pytest.param( + Language.POWERSHELL, + "main.ps1", + "Convert-ToUpperCase", + 0, + 1, + "function Convert-ToUpperCase ()", + "utils.ps1", + marks=pytest.mark.powershell, + ), + pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp), + pytest.param( + Language.LEAN4, + "Main.lean", + "add", + 0, + 1, + "add", + "Helper.lean", + marks=[ + pytest.mark.lean4, + pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"), + pytest.mark.xfail(reason="Lean4 LS does not reliably resolve cross-file defining symbols in CI"), + ], + ), + pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript), + pytest.param( + Language.FSHARP, + "Program.fs", + "add", + 0, + 1, + "add", + "Calculator.fs", + marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")], + ), + ], + indirect=["language_server"], +) +def test_request_defining_symbol_matrix( + language_server: SolidLanguageServer, + relative_path: str, + identifier: str, + occurrence_index: int, + column_offset: int, + expected_name: str, + expected_definition_file: str, +) -> None: + repo_root = Path(language_server.repository_root_path) + position = find_identifier_occurrence_position(repo_root / relative_path, identifier, occurrence_index, column_offset) + assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}" + + defining_symbol = language_server.request_defining_symbol(relative_path, *position) + assert defining_symbol is not None, f"Expected a defining symbol for {identifier!r} in {relative_path}" + assert defining_symbol.get("name") == expected_name, f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" + assert expected_definition_file in defining_symbol["location"].get("relativePath", ""), ( + f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" + ) diff --git a/test/solidlsp/test_diagnostics_matrix.py b/test/solidlsp/test_diagnostics_matrix.py new file mode 100644 index 00000000..2d1b50fe --- /dev/null +++ b/test/solidlsp/test_diagnostics_matrix.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import pytest + +from serena.symbol import LanguageServerSymbolRetriever +from solidlsp import SolidLanguageServer +from test.conftest import find_identifier_position +from test.diagnostics_cases import DIAGNOSTIC_CASE_PARAMS, DiagnosticCase + + +@pytest.mark.parametrize("language_server,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["language_server"]) +def test_request_text_document_diagnostics_matrix( + language_server: SolidLanguageServer, + diagnostic_case: DiagnosticCase, +) -> None: + diagnostics = language_server.request_text_document_diagnostics(diagnostic_case.relative_path, min_severity=1) + assert diagnostics, f"Expected diagnostics for {diagnostic_case.language.value}:{diagnostic_case.relative_path}" + + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + assert any(diagnostic_case.primary_message_fragment in message for message in diagnostic_messages), diagnostic_messages + assert any(diagnostic_case.reference_message_fragment in message for message in diagnostic_messages), diagnostic_messages + + repo_root = language_server.repository_root_path + primary_symbol_position = find_identifier_position( + Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier + ) + reference_symbol_position = find_identifier_position( + Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier + ) + assert primary_symbol_position is not None + assert reference_symbol_position is not None + + primary_diagnostics = language_server.request_text_document_diagnostics( + diagnostic_case.relative_path, + start_line=primary_symbol_position[0], + end_line=reference_symbol_position[0] - 1, + min_severity=1, + ) + primary_messages = [diagnostic["message"] for diagnostic in primary_diagnostics] + assert primary_messages, f"Expected range-filtered diagnostics for {diagnostic_case.primary_symbol_identifier}" + assert any(diagnostic_case.primary_message_fragment in message for message in primary_messages), primary_messages + assert all(diagnostic_case.reference_message_fragment not in message for message in primary_messages), primary_messages + + +@pytest.mark.parametrize("project_with_ls,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["project_with_ls"]) +def test_get_symbol_diagnostics_matrix(project_with_ls, diagnostic_case: DiagnosticCase) -> None: + symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) + + diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( + diagnostic_case.primary_symbol_name_path, + reference_file=diagnostic_case.relative_path, + min_severity=1, + ) + diagnostic_messages_by_symbol = { + symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics] + for symbol, diagnostics in diagnostics_by_symbol.items() + } + assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_by_symbol, diagnostic_messages_by_symbol + assert any( + diagnostic_case.primary_message_fragment in message + for message in diagnostic_messages_by_symbol[diagnostic_case.primary_symbol_name_path] + ), diagnostic_messages_by_symbol + + diagnostics_with_references = symbol_retriever.get_symbol_diagnostics( + diagnostic_case.primary_symbol_name_path, + reference_file=diagnostic_case.relative_path, + check_symbol_references=True, + min_severity=1, + ) + diagnostic_messages_with_references = { + symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics] + for symbol, diagnostics in diagnostics_with_references.items() + } + assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references + assert diagnostic_case.reference_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references + assert any( + diagnostic_case.reference_message_fragment in message + for message in diagnostic_messages_with_references[diagnostic_case.reference_symbol_name_path] + ), diagnostic_messages_with_references diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index e5e00915..cfa1d11d 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -3,13 +3,13 @@ import os import pytest from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language +from test.conftest import PYTHON_LANGUAGE_BACKENDS class TestLanguageServerCommonFunctionality: """Test common functionality of SolidLanguageServer base implementation (not language-specific behaviour).""" - @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) def test_open_file_cache_invalidate(self, language_server: SolidLanguageServer) -> None: """ Tests that the file buffer cache is invalidated when the file is changed on disk. diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py index ba86ba04..fa9b1566 100644 --- a/test/solidlsp/typescript/test_typescript_basic.py +++ b/test/solidlsp/typescript/test_typescript_basic.py @@ -5,6 +5,7 @@ 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, language_has_verified_implementation_support from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols @@ -33,6 +34,33 @@ class TestTypescriptLanguageServer: "index.ts should reference helperFunction (tried all positions in selectionRange)" ) + if language_has_verified_implementation_support(Language.TYPESCRIPT): + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_find_implementations(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.TYPESCRIPT) + pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementations = language_server.request_implementation("formatters.ts", *pos) + assert implementations, "Expected at least one implementation of Greeter.formatGreeting" + assert any("formatters.ts" in implementation.get("relativePath", "") for implementation in implementations), ( + f"Expected ConsoleGreeter.formatGreeting in implementations, got: {implementations}" + ) + + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_request_implementing_symbols(self, language_server: SolidLanguageServer) -> None: + repo_path = get_repo_path(Language.TYPESCRIPT) + pos = find_identifier_position(repo_path / "formatters.ts", "formatGreeting") + assert pos is not None, "Could not find Greeter.formatGreeting in fixture" + + implementing_symbols = language_server.request_implementing_symbols("formatters.ts", *pos) + assert implementing_symbols, "Expected implementing symbols for Greeter.formatGreeting" + assert any( + symbol.get("name") == "formatGreeting" and "formatters.ts" in symbol["location"].get("relativePath", "") + for symbol in implementing_symbols + ), f"Expected ConsoleGreeter.formatGreeting symbol, got: {implementing_symbols}" + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server) From 940bbb31def37893426b459a2da4805c3a4848a8 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 25 Mar 2026 17:01:23 +0100 Subject: [PATCH 34/48] Restructured serena_agent tests, reifying the cases Removed some cases (haxe, msl) during cherry pick due to conflicts. We don't need to cover these languages in this test --- test/conftest.py | 2 +- test/diagnostics_cases.py | 230 --- test/serena/test_serena_agent.py | 1818 +++++++++--------- test/solidlsp/test_defining_symbol_matrix.py | 139 -- test/solidlsp/test_diagnostics_matrix.py | 79 - 5 files changed, 912 insertions(+), 1356 deletions(-) delete mode 100644 test/diagnostics_cases.py delete mode 100644 test/solidlsp/test_defining_symbol_matrix.py delete mode 100644 test/solidlsp/test_diagnostics_matrix.py diff --git a/test/conftest.py b/test/conftest.py index 5e55b180..7672bde6 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -370,7 +370,7 @@ def find_identifier_position(file_path: Path, identifier: str) -> tuple[int, int return None -def find_identifier_occurrence_position( +def find_identifier_pos( file_path: Path, identifier: str, occurrence_index: int = 0, diff --git a/test/diagnostics_cases.py b/test/diagnostics_cases.py deleted file mode 100644 index 8cfcfacb..00000000 --- a/test/diagnostics_cases.py +++ /dev/null @@ -1,230 +0,0 @@ -import os -from dataclasses import dataclass -from typing import cast - -import pytest -from _pytest.mark import Mark, MarkDecorator - -from solidlsp.ls_config import Language -from test.conftest import get_pytest_markers - - -@dataclass(frozen=True) -class DiagnosticCase: - language: Language - relative_path: str - primary_symbol_name_path: str - primary_symbol_identifier: str - reference_symbol_name_path: str - reference_symbol_identifier: str - primary_message_fragment: str - reference_message_fragment: str - - -def diagnostic_case_param( - case: DiagnosticCase, - *marks: MarkDecorator | Mark, - id: str, -): - return pytest.param(case.language, case, marks=[*get_pytest_markers(case.language), *marks], id=id) - - -DIAGNOSTIC_CASE_PARAMS = [ - diagnostic_case_param( - DiagnosticCase( - language=Language.PYTHON, - relative_path=os.path.join("test_repo", "diagnostics_sample.py"), - primary_symbol_name_path="broken_factory", - primary_symbol_identifier="broken_factory", - reference_symbol_name_path="broken_consumer", - reference_symbol_identifier="broken_consumer", - primary_message_fragment="missing_user", - reference_message_fragment="undefined_name", - ), - id="python_missing_user", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.PYTHON_TY, - relative_path=os.path.join("test_repo", "diagnostics_sample.py"), - primary_symbol_name_path="broken_factory", - primary_symbol_identifier="broken_factory", - reference_symbol_name_path="broken_consumer", - reference_symbol_identifier="broken_consumer", - primary_message_fragment="missing_user", - reference_message_fragment="undefined_name", - ), - id="python_ty_missing_user", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.GO, - relative_path="diagnostics_sample.go", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - id="go_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.JAVA, - relative_path=os.path.join("src", "main", "java", "test_repo", "DiagnosticsSample.java"), - primary_symbol_name_path="DiagnosticsSample/brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="DiagnosticsSample/brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - id="java_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.KOTLIN, - relative_path=os.path.join("src", "main", "kotlin", "test_repo", "DiagnosticsSample.kt"), - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - id="kotlin_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.RUST, - relative_path=os.path.join("src", "diagnostics_sample.rs"), - primary_symbol_name_path="broken_factory", - primary_symbol_identifier="broken_factory", - reference_symbol_name_path="broken_consumer", - reference_symbol_identifier="broken_consumer", - primary_message_fragment="missing_greeting", - reference_message_fragment="missing_consumer_value", - ), - pytest.mark.xfail(reason="rust-analyzer does not surface diagnostics for this fixture through Serena currently"), - id="rust_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.PHP, - relative_path="diagnostics_sample.php", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.xfail(reason="PHP LS integration does not expose document diagnostics in this environment"), - id="php_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.CLOJURE, - relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"), - primary_symbol_name_path="broken-factory", - primary_symbol_identifier="broken-factory", - reference_symbol_name_path="broken-consumer", - reference_symbol_identifier="broken-consumer", - primary_message_fragment="missing-greeting", - reference_message_fragment="missing-consumer-value", - ), - id="clojure_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.CSHARP, - relative_path="DiagnosticsSample.cs", - primary_symbol_name_path="TestProject/DiagnosticsSample/BrokenFactory", - primary_symbol_identifier="BrokenFactory", - reference_symbol_name_path="TestProject/DiagnosticsSample/BrokenConsumer", - reference_symbol_identifier="BrokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - id="csharp_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.POWERSHELL, - relative_path="diagnostics_sample.ps1", - primary_symbol_name_path="function Invoke-BrokenFactory ()", - primary_symbol_identifier="Invoke-BrokenFactory", - reference_symbol_name_path="function Invoke-BrokenConsumer ()", - reference_symbol_identifier="Invoke-BrokenConsumer", - primary_message_fragment="MissingGreeting", - reference_message_fragment="MissingConsumerValue", - ), - pytest.mark.xfail(reason="PowerShell LS does not surface document diagnostics in this environment"), - id="powershell_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.CPP_CCLS, - relative_path="diagnostics_sample.cpp", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.xfail(reason="ccls does not expose document diagnostics through this integration"), - id="cpp_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.LEAN4, - relative_path="DiagnosticsSample.lean", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.xfail(reason="Lean4 LS does not reliably surface diagnostics in CI"), - id="lean_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.TYPESCRIPT, - relative_path="diagnostics_sample.ts", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.typescript, - pytest.mark.xfail(reason="TypeScript LS does not surface document diagnostics through this integration"), - id="typescript_missing_greeting", - ), - diagnostic_case_param( - DiagnosticCase( - language=Language.FSHARP, - relative_path="DiagnosticsSample.fs", - primary_symbol_name_path="brokenFactory", - primary_symbol_identifier="brokenFactory", - reference_symbol_name_path="brokenConsumer", - reference_symbol_identifier="brokenConsumer", - primary_message_fragment="missingGreeting", - reference_message_fragment="missingConsumerValue", - ), - pytest.mark.xfail(reason="F# LS does not expose document diagnostics through this integration"), - id="fsharp_missing_greeting", - ), -] - -WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS = [ - case_param - for case_param in DIAGNOSTIC_CASE_PARAMS - if cast(DiagnosticCase, case_param.values[1]).language - in {Language.PYTHON, Language.PYTHON_TY, Language.GO, Language.JAVA, Language.KOTLIN, Language.CSHARP} -] diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 62dc1a53..a577b849 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -2,15 +2,15 @@ import json import logging import os import re -import shutil import time from collections.abc import Iterator from contextlib import contextmanager -from typing import Literal +from copy import copy +from dataclasses import dataclass +from typing import Literal, cast import pytest -from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase -from _pytest.mark import Mark, MarkDecorator +from _pytest.mark import Mark, MarkDecorator, ParameterSet from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig @@ -24,7 +24,6 @@ from serena.tools import ( FindReferencingSymbolsTool, FindSymbolTool, GetDiagnosticsForFileTool, - GetDiagnosticsForSymbolTool, InitialInstructionsTool, ReplaceContentTool, ReplaceSymbolBodyTool, @@ -34,205 +33,726 @@ from serena.tools import ( from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind from test.conftest import ( - find_identifier_occurrence_position, + find_identifier_pos, + get_pytest_markers, get_repo_path, is_ci, - language_has_verified_implementation_support, language_tests_enabled, ) -from test.diagnostics_cases import WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, DiagnosticCase -from test.solidlsp import clojure as clj -DEFINING_SYMBOL_TOOL_TEST_CASES = [ - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - "User", - 1, - 1, - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON_TY, - os.path.join("test_repo", "services.py"), - "User", - 1, - 1, - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - os.path.join("src", "main", "java", "test_repo", "Main.java"), - "Model", - 0, - 1, - "Model", - "Model.java", - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), - "Model", - 0, - 1, - "Model", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.RUST, - os.path.join("src", "main.rs"), - "format_greeting", - 0, - 1, - "format_greeting", - "lib.rs", - marks=pytest.mark.rust, - ), - pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - clj.UTILS_PATH, - "multiply", - 0, - 1, - "multiply", - clj.CORE_PATH, - marks=[ - pytest.mark.clojure, - pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"), - ], - ), - pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp), - pytest.param( - Language.POWERSHELL, - "main.ps1", - "Convert-ToUpperCase", - 0, - 1, - "function Convert-ToUpperCase ()", - "utils.ps1", - marks=pytest.mark.powershell, - ), - pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp), - pytest.param( - Language.LEAN4, - "Main.lean", - "add", - 0, - 1, - "add", - "Helper.lean", - marks=[ - pytest.mark.lean4, - pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"), - ], - ), - pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript), - pytest.param( - Language.FSHARP, - "Program.fs", - "add", - 0, - 1, - "add", - "Calculator.fs", - marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")], + +@dataclass +class BaseCase: + language: Language + 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 + + +@dataclass +class FindSymbolCase(BaseCase): + symbol_name: str + expected_kind: str + expected_file: str + + +@dataclass +class FindReferenceCase(BaseCase): + symbol_name: str + definition_file: str + reference_file: str + + +@dataclass +class FindDefiningSymbolCase(BaseCase): + relative_path: str + identifier: str + occurrence_index: int + column_offset: int + expected_name: str + expected_definition_file: str + + +@dataclass +class RegexDefiningSymbolCase(BaseCase): + relative_path: str + regex: str + containing_symbol_name_path: str + expected_name: str + expected_definition_file: str + + +@dataclass +class RegexDefiningSymbolErrorCase(BaseCase): + relative_path: str + regex: str + containing_symbol_name_path: str + error_fragment: str + + +@dataclass +class FindImplementationCase(BaseCase): + symbol_name: str + definition_file: str + implementation_file: str + expected_symbol_name: str + + +@dataclass +class FindSymbolNamePathCase(BaseCase): + name_path: str + substring_matching: bool + expected_symbol_name: str + expected_kind: str + expected_file: str + + +@dataclass +class FindSymbolNoMatchCase(BaseCase): + name_path: str + + +@dataclass +class FindSymbolOverloadedCase(BaseCase): + name_path: str + num_expected: int + + +@dataclass +class NonUniqueSymbolReferenceCase(BaseCase): + name_path: str + relative_path: str + expected_error_fragment: str = "multiple" + + +@dataclass +class SafeDeleteCase(BaseCase): + name_path: str + relative_path: str + + def __post_init__(self) -> None: + object.__setattr__(self, "relative_path", self.relative_path.replace("\\", "/")) + + +@dataclass +class DiagnosticCase(BaseCase): + language: Language + relative_path: str + name_path1: str + name_path2: str | None + message_fragment1: str + message_fragment2: str | None + + @property + def symbol1_id_str(self) -> str: + return self.name_path1.split("/")[-1] + + @property + def symbol2_id_str(self) -> str | None: + if self.name_path2 is None: + return None + return self.name_path2.split("/")[-1] + + def without_second_symbol(self) -> "DiagnosticCase": + result = copy(self) + result.name_path2 = None + result.message_fragment2 = None + return result + + def assert_matches(self, tool_output: dict) -> None: + """ + + :param tool_output: Output of diagnostics tool, representing the mapping `relative_path -> severity -> name_path -> diagnostics_results`. + :return: + """ + assert self.relative_path in tool_output, ( + f"Missing diagnostics for relative path {self.relative_path} in tool output keys: {list(tool_output.keys())}" + ) + severity_group = tool_output[self.relative_path] + assert "Error" in severity_group, severity_group + name_path_group = severity_group["Error"] + + for expected_name_path in [self.name_path1, self.name_path2]: + if expected_name_path is not None: + assert expected_name_path in name_path_group, name_path_group + + diagnostic_messages = [ + diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path + ] + for expected_fragment in [self.message_fragment1, self.message_fragment2]: + if expected_fragment is not None: + assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages + + +DIAGNOSTIC_CASES = [ + DiagnosticCase( + language=Language.PYTHON, + id=f"{Language.PYTHON.value}_missing_user", + relative_path=os.path.join("test_repo", "diagnostics_sample.py"), + name_path1="broken_factory", + name_path2="broken_consumer", + message_fragment1="missing_user", + message_fragment2="undefined_name", + ).to_pytest_param(), + DiagnosticCase( + language=Language.CLOJURE, + id=f"{Language.CLOJURE.value}_missing-greeting", + relative_path=os.path.join("src", "test_app", "diagnostics_sample.clj"), + name_path1="broken-factory", + name_path2="broken-consumer", + message_fragment1="missing-greeting", + message_fragment2="missing-consumer-value", + ).to_pytest_param(), + DiagnosticCase( + language=Language.GO, + id=f"{Language.GO.value}_missingGreeting", + relative_path="diagnostics_sample.go", + name_path1="brokenFactory", + name_path2="brokenConsumer", + message_fragment1="missingGreeting", + message_fragment2="missingConsumerValue", + ).to_pytest_param(), + DiagnosticCase( + language=Language.TYPESCRIPT, + id=f"{Language.TYPESCRIPT.value}_missingGreeting", + relative_path="diagnostics_sample.ts", + name_path1="brokenFactory", + name_path2="brokenConsumer", + message_fragment1="missingGreeting", + message_fragment2="missingConsumerValue", + ).to_pytest_param(), +] + + +FIND_DEFINING_SYMBOL_CASES = [ + FindDefiningSymbolCase( + language=Language.PYTHON, + id="python_user_in_services", + relative_path=os.path.join("test_repo", "services.py"), + identifier="User", + occurrence_index=1, + column_offset=1, + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.PYTHON_TY, + id="python_ty_user_in_services", + relative_path=os.path.join("test_repo", "services.py"), + identifier="User", + occurrence_index=1, + column_offset=1, + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.GO, + id="go_helper_in_main", + relative_path="main.go", + identifier="Helper", + occurrence_index=0, + column_offset=1, + expected_name="Helper", + expected_definition_file="main.go", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.JAVA, + id="java_model_in_main", + relative_path=os.path.join("src", "main", "java", "test_repo", "Main.java"), + identifier="Model", + occurrence_index=0, + column_offset=1, + expected_name="Model", + expected_definition_file="Model.java", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.KOTLIN, + id="kotlin_model_in_main", + relative_path=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + identifier="Model", + occurrence_index=0, + column_offset=1, + expected_name="Model", + expected_definition_file="Model.kt", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.RUST, + id="rust_format_greeting", + relative_path=os.path.join("src", "main.rs"), + identifier="format_greeting", + occurrence_index=0, + column_offset=1, + expected_name="format_greeting", + expected_definition_file="lib.rs", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.PHP, + id="php_helper_function", + relative_path="index.php", + identifier="helperFunction", + occurrence_index=0, + column_offset=5, + expected_name="helperFunction", + expected_definition_file="helper.php", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CLOJURE, + id="clojure_multiply_in_utils", + relative_path=os.path.join("src", "test_app", "utils.clj"), + identifier="multiply", + occurrence_index=0, + column_offset=1, + expected_name="multiply", + expected_definition_file=os.path.join("src", "test_app", "core.clj"), + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CSHARP, + id="csharp_add_in_program", + relative_path="Program.cs", + identifier="Add", + occurrence_index=0, + column_offset=1, + expected_name="Add", + expected_definition_file="Program.cs", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.POWERSHELL, + id="powershell_convert_to_uppercase", + relative_path="main.ps1", + identifier="Convert-ToUpperCase", + occurrence_index=0, + column_offset=1, + expected_name="function Convert-ToUpperCase ()", + expected_definition_file="utils.ps1", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.CPP_CCLS, + id="cpp_add_in_a", + relative_path="a.cpp", + identifier="add", + occurrence_index=0, + column_offset=1, + expected_name="add", + expected_definition_file="b.cpp", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.LEAN4, + id="lean_add_in_main", + relative_path="Main.lean", + identifier="add", + occurrence_index=1, + column_offset=1, + expected_name="add", + expected_definition_file="Helper.lean", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.TYPESCRIPT, + id="typescript_helper_function", + relative_path="index.ts", + identifier="helperFunction", + occurrence_index=1, + column_offset=1, + expected_name="helperFunction", + expected_definition_file="index.ts", + ).to_pytest_param(), + FindDefiningSymbolCase( + language=Language.FSHARP, + id="fsharp_add_in_program", + relative_path="Program.fs", + identifier="add", + occurrence_index=0, + column_offset=1, + expected_name="add", + expected_definition_file="Calculator.fs", + ).to_pytest_param( + pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols"), ), ] -REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES = [ - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - r"from \.models import Item, (User)", - "", - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - r"=\s+(User)\(", - "UserService/create_user", - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON_TY, - os.path.join("test_repo", "services.py"), - r"=\s+(User)\(", - "UserService/create_user", - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.GO, - "main.go", - r"var greeter (Greeter) =", - "main", - "Greeter", - "main.go", - marks=pytest.mark.go, +FIND_DEFINING_SYMBOL_REGEX_CASES = [ + RegexDefiningSymbolCase( + language=Language.PYTHON, + id="python_import_user", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"from \.models import Item, (User)", + containing_symbol_name_path="", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.PYTHON, + id="python_create_user_call", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"=\s+(User)\(", + containing_symbol_name_path="UserService/create_user", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.PYTHON_TY, + id="python_ty_create_user_call", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"=\s+(User)\(", + containing_symbol_name_path="UserService/create_user", + expected_name="User", + expected_definition_file="models.py", + ).to_pytest_param(), + RegexDefiningSymbolCase( + language=Language.GO, + id="go_greeter_var", + relative_path="main.go", + regex=r"var greeter (Greeter) =", + containing_symbol_name_path="main", + expected_name="Greeter", + expected_definition_file="main.go", + ).to_pytest_param(), +] + +FIND_IMPLEMENTATION_CASES = [ + FindImplementationCase( + language=Language.CSHARP, + id="csharp_greeter_format", + symbol_name="IGreeter/FormatGreeting", + definition_file=os.path.join("Services", "IGreeter.cs"), + implementation_file=os.path.join("Services", "ConsoleGreeter.cs"), + expected_symbol_name="FormatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.GO, + id="go_greeter_format", + symbol_name="Greeter/FormatGreeting", + definition_file="main.go", + implementation_file="main.go", + expected_symbol_name="FormatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.JAVA, + id="java_greeter_format", + symbol_name="Greeter/formatGreeting", + definition_file=os.path.join("src", "main", "java", "test_repo", "Greeter.java"), + implementation_file=os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"), + expected_symbol_name="formatGreeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.RUST, + id="rust_greeter_format", + symbol_name="Greeter/format_greeting", + definition_file=os.path.join("src", "lib.rs"), + implementation_file=os.path.join("src", "lib.rs"), + expected_symbol_name="format_greeting", + ).to_pytest_param(), + FindImplementationCase( + language=Language.TYPESCRIPT, + id="typescript_greeter_format", + symbol_name="Greeter/formatGreeting", + definition_file="formatters.ts", + implementation_file="formatters.ts", + expected_symbol_name="formatGreeting", + ).to_pytest_param(), +] + + +FIND_SYMBOL_REFERENCES_CASES = [ + FindSymbolCase( + language=Language.PYTHON, id="python_user_class", symbol_name="User", expected_kind="Class", expected_file="models.py" + ).to_pytest_param(), + FindSymbolCase( + language=Language.GO, id="go_helper_function", symbol_name="Helper", expected_kind="Function", expected_file="main.go" + ).to_pytest_param(), + FindSymbolCase( + language=Language.JAVA, id="java_model_class", symbol_name="Model", expected_kind="Class", expected_file="Model.java" + ).to_pytest_param(), + FindSymbolCase( + language=Language.KOTLIN, id="kotlin_model_struct", symbol_name="Model", expected_kind="Struct", expected_file="Model.kt" + ).to_pytest_param(), + FindSymbolCase( + language=Language.TYPESCRIPT, + id="typescript_demo_class", + symbol_name="DemoClass", + expected_kind="Class", + expected_file="index.ts", + ).to_pytest_param(), + FindSymbolCase( + language=Language.PHP, + id="php_helper_function", + symbol_name="helperFunction", + expected_kind="Function", + expected_file="helper.php", + ).to_pytest_param(), + FindSymbolCase( + language=Language.CLOJURE, + id="clojure_greet_function", + symbol_name="greet", + expected_kind="Function", + expected_file=os.path.join("src", "test_app", "core.clj"), + ).to_pytest_param(), + FindSymbolCase( + language=Language.CSHARP, + id="csharp_calculator_class", + symbol_name="Calculator", + expected_kind="Class", + expected_file="Program.cs", + ).to_pytest_param(), + FindSymbolCase( + language=Language.POWERSHELL, + id="powershell_greet_user", + symbol_name="Greet-User", + expected_kind="Function", + expected_file="main.ps1", + ).to_pytest_param(), + FindSymbolCase( + language=Language.CPP_CCLS, id="cpp_add_function", symbol_name="add", expected_kind="Function", expected_file="b.cpp" + ).to_pytest_param(), + FindSymbolCase( + language=Language.LEAN4, id="lean_add_method", symbol_name="add", expected_kind="Method", expected_file="Helper.lean" + ).to_pytest_param(), + FindSymbolCase( + language=Language.FSHARP, + id="fsharp_calculator_module", + symbol_name="Calculator", + expected_kind="Module", + expected_file="Calculator.fs", + ).to_pytest_param(), + FindSymbolCase( + language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs" + ).to_pytest_param(), +] + +FIND_REFERENCE_CASES = [ + FindReferenceCase( + language=Language.PYTHON, + id="python_user_refs", + symbol_name="User", + definition_file=os.path.join("test_repo", "models.py"), + reference_file=os.path.join("test_repo", "services.py"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.GO, id="go_helper_refs", symbol_name="Helper", definition_file="main.go", reference_file="main.go" + ).to_pytest_param(), + FindReferenceCase( + language=Language.JAVA, + id="java_model_refs", + symbol_name="Model", + definition_file=os.path.join("src", "main", "java", "test_repo", "Model.java"), + reference_file=os.path.join("src", "main", "java", "test_repo", "Main.java"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.KOTLIN, + id="kotlin_model_refs", + symbol_name="Model", + definition_file=os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + reference_file=os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.RUST, + id="rust_add_refs", + symbol_name="add", + definition_file=os.path.join("src", "lib.rs"), + reference_file=os.path.join("src", "main.rs"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.PHP, + id="php_helper_refs", + symbol_name="helperFunction", + definition_file="helper.php", + reference_file="index.php", + ).to_pytest_param(), + FindReferenceCase( + language=Language.CLOJURE, + id="clojure_multiply_refs", + symbol_name="multiply", + definition_file=os.path.join("src", "test_app", "core.clj"), + reference_file=os.path.join("src", "test_app", "utils.clj"), + ).to_pytest_param(), + FindReferenceCase( + language=Language.CSHARP, + id="csharp_calculator_refs", + symbol_name="Calculator", + definition_file="Program.cs", + reference_file="Program.cs", + ).to_pytest_param(), + FindReferenceCase( + language=Language.POWERSHELL, + id="powershell_greet_user_refs", + symbol_name="Greet-User", + definition_file="main.ps1", + reference_file="main.ps1", + ).to_pytest_param(), + FindReferenceCase( + language=Language.CPP_CCLS, id="cpp_add_refs", symbol_name="add", definition_file="b.cpp", reference_file="a.cpp" + ).to_pytest_param(), + FindReferenceCase( + language=Language.LEAN4, id="lean_add_refs", symbol_name="add", definition_file="Helper.lean", reference_file="Main.lean" + ).to_pytest_param(), + FindReferenceCase( + language=Language.TYPESCRIPT, + id="typescript_helper_refs", + symbol_name="helperFunction", + definition_file="index.ts", + reference_file="use_helper.ts", + ).to_pytest_param(pytest.mark.xfail(False, reason="TypeScript language server is unreliable")), + FindReferenceCase( + language=Language.FSHARP, id="fsharp_add_refs", symbol_name="add", definition_file="Calculator.fs", reference_file="Program.fs" + ).to_pytest_param( + pytest.mark.xfail(reason="F# language server is unreliable"), # See issue #1040 ), ] -IMPLEMENTATION_TOOL_TEST_CASE_DATA = [ - ( - Language.CSHARP, - "IGreeter/FormatGreeting", - os.path.join("Services", "IGreeter.cs"), - os.path.join("Services", "ConsoleGreeter.cs"), - "FormatGreeting", - pytest.mark.csharp, - ), - ( - Language.GO, - "Greeter/FormatGreeting", - "main.go", - "main.go", - "(ConsoleGreeter).FormatGreeting", - pytest.mark.go, - ), - ( - Language.JAVA, - "Greeter/formatGreeting", - os.path.join("src", "main", "java", "test_repo", "Greeter.java"), - os.path.join("src", "main", "java", "test_repo", "ConsoleGreeter.java"), - "formatGreeting", - pytest.mark.java, - ), - ( - Language.RUST, - "Greeter/format_greeting", - os.path.join("src", "lib.rs"), - os.path.join("src", "lib.rs"), - "format_greeting", - pytest.mark.rust, - ), - ( - Language.TYPESCRIPT, - "Greeter/formatGreeting", - "formatters.ts", - "formatters.ts", - "formatGreeting", - pytest.mark.typescript, - ), +FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [ + RegexDefiningSymbolErrorCase( + language=Language.PYTHON, + id="python_regex_multiple_matches", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"(User)", + containing_symbol_name_path="", + error_fragment="Expected exactly one regex match", + ).to_pytest_param(), + RegexDefiningSymbolErrorCase( + language=Language.PYTHON, + id="python_regex_missing_group", + relative_path=os.path.join("test_repo", "services.py"), + regex=r"User", + containing_symbol_name_path="UserService/create_user", + error_fragment="must contain exactly one capturing group", + ).to_pytest_param(), ] -IMPLEMENTATION_TOOL_TEST_CASES = [ - pytest.param(language, symbol_name, def_file, impl_file, expected_symbol_name, marks=mark) - for language, symbol_name, def_file, impl_file, expected_symbol_name, mark in IMPLEMENTATION_TOOL_TEST_CASE_DATA - if language_has_verified_implementation_support(language) +FIND_SYMBOL_NAME_PATH_CASES = [ + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_class_exact", + name_path="OuterClass/NestedClass", + substring_matching=False, + expected_symbol_name="NestedClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_exact", + name_path="OuterClass/NestedClass/find_me", + substring_matching=False, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_class_substring", + name_path="OuterClass/NestedCl", + substring_matching=True, + expected_symbol_name="NestedClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_substring", + name_path="OuterClass/NestedClass/find_m", + substring_matching=True, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="outer_class_absolute", + name_path="/OuterClass", + substring_matching=False, + expected_symbol_name="OuterClass", + expected_kind="Class", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), + FindSymbolNamePathCase( + language=Language.PYTHON, + id="nested_method_absolute_substring", + name_path="/OuterClass/NestedClass/find_m", + substring_matching=True, + expected_symbol_name="find_me", + expected_kind="Method", + expected_file=os.path.join("test_repo", "nested.py"), + ).to_pytest_param(), +] + +FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES = [ + FindSymbolNoMatchCase(language=Language.PYTHON, id="nested_class_not_top_level", name_path="/NestedClass").to_pytest_param(), + FindSymbolNoMatchCase( + language=Language.PYTHON, id="nested_class_missing_parent", name_path="/NoSuchParent/NestedClass" + ).to_pytest_param(), +] + +FIND_SYMBOL_OVERLOADED_FUNCTION_CASES = [ + FindSymbolOverloadedCase( + language=Language.JAVA, id="java_overloaded_get_name", name_path="Model/getName", num_expected=2 + ).to_pytest_param(), +] + +NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES = [ + NonUniqueSymbolReferenceCase( + language=Language.JAVA, + id="java_overloaded_get_name", + name_path="Model/getName", + relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"), + ).to_pytest_param(), +] + +SAFE_DELETE_BLOCKED_CASES = [ + SafeDeleteCase( + language=Language.PYTHON, + id="python_user", + name_path="User", + relative_path=os.path.join("test_repo", "models.py"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.JAVA, + id="java_model", + name_path="Model", + relative_path=os.path.join("src", "main", "java", "test_repo", "Model.java"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.KOTLIN, + 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 []), + ), + SafeDeleteCase( + language=Language.TYPESCRIPT, + id="typescript_helper_function", + name_path="helperFunction", + relative_path="index.ts", + ).to_pytest_param(), +] + +SAFE_DELETE_SUCCEEDS_CASES = [ + SafeDeleteCase( + language=Language.PYTHON, + id="python_timer", + name_path="Timer", + relative_path=os.path.join("test_repo", "utils.py"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.JAVA, + id="java_model_user", + name_path="ModelUser", + relative_path=os.path.join("src", "main", "java", "test_repo", "ModelUser.java"), + ).to_pytest_param(), + SafeDeleteCase( + language=Language.KOTLIN, + 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 []), + ), + SafeDeleteCase( + language=Language.TYPESCRIPT, + id="typescript_unused_standalone_function", + name_path="unusedStandaloneFunction", + relative_path="index.ts", + ).to_pytest_param(), ] @@ -334,7 +854,11 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere class TestSerenaAgent: - @pytest.mark.parametrize("project", [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"]) + @pytest.mark.parametrize( + "project", + [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"], + ids=["no_project", "python_project_path", "invalid_project_path"], + ) def test_agent_instantiation(self, project: str | None): """ Tests agent instantiation for cases where @@ -346,113 +870,62 @@ class TestSerenaAgent: serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False) SerenaAgent(project=project, serena_config=serena_config) - def _assert_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - agent = serena_agent - find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True) + def _symbol_matches_expected_name(self, symbol: dict, expected_name: str) -> bool: + return ( + symbol.get("name") == expected_name + or symbol.get("name_path", "").split("/")[-1] == expected_name + or expected_name in symbol.get("info", "") + ) - symbols = json.loads(result) - assert any( - symbol_name in s["name_path"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"] - for s in symbols - ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}" - # testing retrieval of symbol info + def _assert_symbol_info_present( + self, + serena_agent: SerenaAgent, + symbol: dict, + expected_name: str | None = None, + ) -> None: if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: # kotlin LS doesn't seem to provide hover info right now, at least for the struct we test this on return - for s in symbols: - if s["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): - # we ignore file and module symbols for the info test - continue - symbol_info = s.get("info") - assert symbol_info, f"Expected symbol info to be present for symbol: {s}" - assert symbol_name in s["info"], ( - f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name {symbol_name}. Info: {s['info']}" + + if symbol["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): + # we ignore file and module symbols for the info test + return + + symbol_info = symbol.get("info") + assert symbol_info, f"Expected symbol info to be present for symbol: {symbol}" + + if expected_name is not None: + assert expected_name in symbol_info, ( + f"[{serena_agent.get_active_lsp_languages()[0]}] Expected symbol info to contain symbol name " + f"{expected_name}. Info: {symbol_info}" ) - # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right - if s["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: - assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}" - @pytest.mark.php - @pytest.mark.parametrize("serena_agent", [Language.PHP], indirect=True) - def test_find_symbol_within_php_file(self, serena_agent: SerenaAgent) -> None: - """Verify find_symbol with a PHP file path routes to the PHP language server. + # special additional test for Java, since Eclipse returns hover in a complex format and we want to make sure to get it right + if symbol["kind"] == SymbolKind.Class.name and serena_agent.get_active_lsp_languages() == [Language.JAVA]: + assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {symbol}" - This validates the fix in symbol.py (LanguageServerSymbolRetriever.find_symbols): - when within_relative_path points to a PHP file, the retriever must use - get_language_server() rather than iterating all language servers. Without this - fix, non-PHP servers reject the PHP file and no symbols are returned. - """ - find_symbol_tool = serena_agent.get_tool(FindSymbolTool) - sample_php = "sample.php" - - result = find_symbol_tool.apply(name_path_pattern="Dog/greet", relative_path=sample_php) - symbols = json.loads(result) - - assert len(symbols) > 0, ( - f"Expected to find Dog/greet in {sample_php} but got empty result. " - "This may indicate that find_symbol is not routing to the PHP language server for PHP files." - ) - assert any("greet" in s["name_path"] and sample_php in s["relative_path"] for s in symbols), ( - f"Dog/greet not found in {sample_php}. Symbols: {symbols}" - ) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), - pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), - pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), - pytest.param( - Language.KOTLIN, - "Model", - "Struct", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), - pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), - pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), - pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.POWERSHELL, "Greet-User", "Function", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), - pytest.param(Language.HAXE, "Main", "Class", "Main.hx", marks=pytest.mark.haxe), - pytest.param(Language.LEAN4, "add", "Method", "Helper.lean", marks=pytest.mark.lean4), - pytest.param(Language.MSL, "greet", "Function", "main.mrc", marks=pytest.mark.msl), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_stable(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 - def test_find_symbol_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="Rust language server is unreliable") # See issue #1040 - def test_find_symbol_rust(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: - self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) - - def _assert_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_REFERENCES_CASES, indirect=["serena_agent"]) + def test_find_symbol(self, serena_agent: SerenaAgent, case: FindSymbolCase) -> None: agent = serena_agent - - # Find the symbol location first find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, include_info=True) + symbols = json.loads(result) + assert any( + case.symbol_name in s["name_path"] + and case.expected_kind.lower() in s["kind"].lower() + and case.expected_file in s["relative_path"] + for s in symbols + ), ( + f"Expected to find {case.symbol_name} ({case.expected_kind}) in {case.expected_file}. Found name paths: {[s['name_path'] for s in symbols]}" + ) + for symbol in symbols: + self._assert_symbol_info_present(serena_agent, symbol, case.symbol_name) + + @pytest.mark.parametrize("serena_agent,case", FIND_REFERENCE_CASES, indirect=["serena_agent"]) + def test_find_symbol_references(self, serena_agent: SerenaAgent, case: FindReferenceCase) -> None: + # Find the symbol location first + find_symbol_tool = serena_agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file) time.sleep(1) symbols = json.loads(result) @@ -460,12 +933,12 @@ class TestSerenaAgent: def_symbol = symbols[0] # Now find references - find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) + find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"]) def contains_ref_with_relative_path(refs, relative_path): """ - Checks for reference to relative path, regardless of output format (grouped an ungrouped) + Checks for reference to relative path, regardless of output format (grouped or ungrouped) """ if isinstance(refs, list): for ref in refs: @@ -480,550 +953,156 @@ class TestSerenaAgent: return False refs = json.loads(result) - assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" + assert contains_ref_with_relative_path(refs, case.reference_file), ( + f"Expected to find reference to {case.symbol_name} in {case.reference_file}. refs={refs}" + ) - def _assert_find_symbol_implementations( + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_CASES, indirect=["serena_agent"]) + def test_find_defining_symbol(self, serena_agent: SerenaAgent, case: FindDefiningSymbolCase) -> None: + project_root = get_repo_path(case.language) + position = find_identifier_pos( + project_root / case.relative_path, + case.identifier, + case.occurrence_index, + case.column_offset, + ) + assert position is not None, f"Could not find occurrence {case.occurrence_index} of {case.identifier!r} in {case.relative_path}" + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool) + result = find_defining_symbol_tool.apply( + relative_path=case.relative_path, + line=position[0], + column=position[1], + include_info=True, + ) + defining_symbol = json.loads(result) + assert defining_symbol is not None, f"Expected defining symbol for {case.identifier!r} in {case.relative_path}" + assert defining_symbol.get("relative_path") is not None + assert case.expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}" + ) + assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), ( + f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}" + ) + self._assert_symbol_info_present(serena_agent, defining_symbol) + + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"]) + def test_find_defining_symbol_by_regex(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None: + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) + result = find_defining_symbol_tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + include_info=True, + ) + defining_symbol = json.loads(result) + assert defining_symbol is not None, f"Expected defining symbol for regex {case.regex!r} in {case.relative_path}" + assert defining_symbol.get("relative_path") is not None + assert case.expected_definition_file in defining_symbol["relative_path"], ( + f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}" + ) + assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), ( + f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}" + ) + self._assert_symbol_info_present(serena_agent, defining_symbol) + + @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"]) + def test_find_defining_symbol_by_regex_error( self, serena_agent: SerenaAgent, - symbol_name: str, - def_file: str, - impl_file: str, - expected_symbol_name: str, + case: RegexDefiningSymbolErrorCase, ) -> None: + find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) + result = find_defining_symbol_tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + ) + assert result.startswith("Error: "), result + assert case.error_fragment in result, result + + @pytest.mark.parametrize("serena_agent,diagnostic_case", DIAGNOSTIC_CASES, indirect=["serena_agent"]) + def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: + diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool) + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + min_severity=1, + ) + full_file_diagnostics = json.loads(result) + diagnostic_case.assert_matches(full_file_diagnostics) + + # testing diagnostics in range by removing second symbol + project_root = get_repo_path(diagnostic_case.language) + pos1 = find_identifier_pos(project_root / diagnostic_case.relative_path, diagnostic_case.symbol1_id_str) + pos2 = find_identifier_pos(project_root / diagnostic_case.relative_path, cast(str, diagnostic_case.symbol2_id_str)) + assert pos1 is not None + assert pos2 is not None + result = diagnostics_tool.apply( + relative_path=diagnostic_case.relative_path, + min_severity=1, + start_line=pos1[0], + end_line=pos2[0] - 1, + ) + diagnostics_in_range = json.loads(result) + diagnostic_case.without_second_symbol().assert_matches(diagnostics_in_range) + + @pytest.mark.parametrize("serena_agent,case", FIND_IMPLEMENTATION_CASES, indirect=["serena_agent"]) + def test_find_symbol_implementations(self, serena_agent: SerenaAgent, case: FindImplementationCase) -> None: agent = serena_agent - find_symbol_tool = agent.get_tool(FindSymbolTool) - result = find_symbol_tool.apply(name_path_pattern=symbol_name, relative_path=def_file) + result = find_symbol_tool.apply(name_path_pattern=case.symbol_name, relative_path=case.definition_file) symbols = json.loads(result) - assert symbols, f"Expected to find symbol {symbol_name} in {def_file}" - + assert symbols, f"Expected to find symbol {case.symbol_name} in {case.definition_file}" def_symbol = symbols[0] find_impl_tool = agent.get_tool(FindImplementationsTool) result = find_impl_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"], include_info=True) implementations = json.loads(result) - assert any( - impl_file in implementation["relative_path"] - and ( - implementation.get("name") == expected_symbol_name - or implementation.get("name_path") == expected_symbol_name - or expected_symbol_name in implementation.get("info", "") - ) + case.implementation_file in implementation["relative_path"] + and self._symbol_matches_expected_name(implementation, case.expected_symbol_name) for implementation in implementations - ), f"Expected to find implementation of {symbol_name} in {impl_file}. implementations={implementations}" - + ), f"Expected to find implementation of {case.symbol_name} in {case.implementation_file}. implementations={implementations}" for implementation in implementations: - if implementation["kind"] in (SymbolKind.File.name, SymbolKind.Module.name): - continue - symbol_info = implementation.get("info") - assert symbol_info, f"Expected symbol info to be present for implementation: {implementation}" + self._assert_symbol_info_present(serena_agent, implementation) - def _assert_find_defining_symbol( - self, - serena_agent: SerenaAgent, - relative_path: str, - identifier: str, - occurrence_index: int, - column_offset: int, - expected_name: str, - expected_definition_file: str, - ) -> None: - project_root = get_repo_path(serena_agent.get_active_lsp_languages()[0]) - position = find_identifier_occurrence_position(project_root / relative_path, identifier, occurrence_index, column_offset) - assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}" - - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool) - result = find_defining_symbol_tool.apply(relative_path=relative_path, line=position[0], column=position[1], include_info=True) - defining_symbol = json.loads(result) - - assert defining_symbol is not None, f"Expected defining symbol for {identifier!r} in {relative_path}" - assert defining_symbol.get("relative_path") is not None - assert expected_definition_file in defining_symbol["relative_path"], ( - f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" - ) - assert ( - defining_symbol.get("name") == expected_name - or defining_symbol.get("name_path") == expected_name - or expected_name in defining_symbol.get("info", "") - ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" - - if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: - return - if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name): - assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}" - - def _assert_find_defining_symbol_by_regex( - self, - serena_agent: SerenaAgent, - relative_path: str, - regex: str, - containing_symbol_name_path: str, - expected_name: str, - expected_definition_file: str, - ) -> None: - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) - result = find_defining_symbol_tool.apply( - regex=regex, - relative_path=relative_path, - containing_symbol_name_path=containing_symbol_name_path, - include_info=True, - ) - defining_symbol = json.loads(result) - - assert defining_symbol is not None, f"Expected defining symbol for regex {regex!r} in {relative_path}" - assert defining_symbol.get("relative_path") is not None - assert expected_definition_file in defining_symbol["relative_path"], ( - f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" - ) - assert ( - defining_symbol.get("name") == expected_name - or defining_symbol.get("name_path") == expected_name - or expected_name in defining_symbol.get("info", "") - ), f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" - - if serena_agent.get_active_lsp_languages() == [Language.KOTLIN]: - return - if defining_symbol["kind"] not in (SymbolKind.File.name, SymbolKind.Module.name): - assert defining_symbol.get("info"), f"Expected defining symbol info to be present: {defining_symbol}" - - def _assert_diagnostics_for_file( - self, - serena_agent: SerenaAgent, - diagnostic_case: DiagnosticCase, - start_line: int = 0, - end_line: int = -1, - ) -> None: - diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForFileTool) - result = diagnostics_tool.apply( - relative_path=diagnostic_case.relative_path, - start_line=start_line, - end_line=end_line, - min_severity=1, - ) - grouped_diagnostics = json.loads(result) - - assert diagnostic_case.relative_path in grouped_diagnostics, grouped_diagnostics - severity_group = grouped_diagnostics[diagnostic_case.relative_path] - assert "Error" in severity_group, severity_group - name_path_group = severity_group["Error"] - - for expected_name_path in [diagnostic_case.primary_symbol_name_path, diagnostic_case.reference_symbol_name_path]: - assert expected_name_path in name_path_group, name_path_group - - diagnostic_messages = [ - diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path - ] - for expected_fragment in [diagnostic_case.primary_message_fragment, diagnostic_case.reference_message_fragment]: - assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages - - def _assert_diagnostics_for_symbol( - self, - serena_agent: SerenaAgent, - diagnostic_case: DiagnosticCase, - check_symbol_references: bool, - ) -> None: - diagnostics_tool = serena_agent.get_tool(GetDiagnosticsForSymbolTool) - result = diagnostics_tool.apply( - name_path=diagnostic_case.primary_symbol_name_path, - reference_file=diagnostic_case.relative_path, - check_symbol_references=check_symbol_references, - min_severity=1, - ) - grouped_diagnostics = json.loads(result) - diagnostics_file = diagnostic_case.relative_path - - assert diagnostics_file in grouped_diagnostics, grouped_diagnostics - severity_group = grouped_diagnostics[diagnostics_file] - assert "Error" in severity_group, severity_group - name_path_group = severity_group["Error"] - - expected_name_paths = [diagnostic_case.primary_symbol_name_path] - expected_message_fragments = [diagnostic_case.primary_message_fragment] - if check_symbol_references: - expected_name_paths.append(diagnostic_case.reference_symbol_name_path) - expected_message_fragments.append(diagnostic_case.reference_message_fragment) - - assert set(expected_name_paths).issubset(name_path_group.keys()), name_path_group - diagnostic_messages = [ - diagnostic["message"] for diagnostics_for_name_path in name_path_group.values() for diagnostic in diagnostics_for_name_path - ] - for expected_fragment in expected_message_fragments: - assert any(expected_fragment in message for message in diagnostic_messages), diagnostic_messages - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param( - Language.PYTHON, - "User", - os.path.join("test_repo", "models.py"), - os.path.join("test_repo", "services.py"), - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - "Model", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - os.path.join("src", "main", "java", "test_repo", "Main.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "Model", - os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), - pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - "multiply", - clj.CORE_PATH, - clj.UTILS_PATH, - marks=pytest.mark.clojure, - ), - pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.POWERSHELL, "Greet-User", "main.ps1", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), - pytest.param( - Language.HAXE, - "addNumbers", - os.path.join("src", "utils", "Helper.hx"), - os.path.join("src", "Main.hx"), - marks=pytest.mark.haxe, - ), - pytest.param(Language.LEAN4, "add", "Helper.lean", "Main.lean", marks=pytest.mark.lean4), - pytest.param(Language.MSL, "format.coins", "utils.mrc", "main.mrc", marks=pytest.mark.msl), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_references_stable(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(False, reason="TypeScript language server is unreliable") # NOTE: Testing; may be resolved by #1120; See issue #1040 - def test_find_symbol_references_typescript(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", - [ - pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), - ], - indirect=["serena_agent"], - ) - @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 - def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) - - @pytest.mark.parametrize( - "serena_agent,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file", - DEFINING_SYMBOL_TOOL_TEST_CASES, - indirect=["serena_agent"], - ) - def test_find_defining_symbol( - self, - serena_agent: SerenaAgent, - relative_path: str, - identifier: str, - occurrence_index: int, - column_offset: int, - expected_name: str, - expected_definition_file: str, - ) -> None: - self._assert_find_defining_symbol( - serena_agent, - relative_path, - identifier, - occurrence_index, - column_offset, - expected_name, - expected_definition_file, - ) - - @pytest.mark.parametrize( - "serena_agent,relative_path,regex,containing_symbol_name_path,expected_name,expected_definition_file", - REGEX_DEFINING_SYMBOL_TOOL_TEST_CASES, - indirect=["serena_agent"], - ) - def test_find_defining_symbol_by_regex( - self, - serena_agent: SerenaAgent, - relative_path: str, - regex: str, - containing_symbol_name_path: str, - expected_name: str, - expected_definition_file: str, - ) -> None: - self._assert_find_defining_symbol_by_regex( - serena_agent, - relative_path, - regex, - containing_symbol_name_path, - expected_name, - expected_definition_file, - ) - - @pytest.mark.parametrize( - "serena_agent,relative_path,regex,containing_symbol_name_path,error_fragment", - [ - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - r"(User)", - "", - "Expected exactly one regex match", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - r"User", - "UserService/create_user", - "must contain exactly one capturing group", - marks=pytest.mark.python, - ), - ], - indirect=["serena_agent"], - ) - def test_find_defining_symbol_by_regex_error( - self, - serena_agent: SerenaAgent, - relative_path: str, - regex: str, - containing_symbol_name_path: str, - error_fragment: str, - ) -> None: - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) - result = find_defining_symbol_tool.apply( - regex=regex, - relative_path=relative_path, - containing_symbol_name_path=containing_symbol_name_path, - ) - assert result.startswith("Error: "), result - assert error_fragment in result, result - - @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) - def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: - self._assert_diagnostics_for_file( - serena_agent, - diagnostic_case=diagnostic_case, - ) - - @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) - def test_get_diagnostics_for_file_in_range(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: - project_root = get_repo_path(diagnostic_case.language) - primary_position = find_identifier_occurrence_position( - project_root / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier - ) - reference_position = find_identifier_occurrence_position( - project_root / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier - ) - assert primary_position is not None - assert reference_position is not None - - self._assert_diagnostics_for_file( - serena_agent, - diagnostic_case=DiagnosticCase( - language=diagnostic_case.language, - relative_path=diagnostic_case.relative_path, - primary_symbol_name_path=diagnostic_case.primary_symbol_name_path, - primary_symbol_identifier=diagnostic_case.primary_symbol_identifier, - reference_symbol_name_path=diagnostic_case.primary_symbol_name_path, - reference_symbol_identifier=diagnostic_case.reference_symbol_identifier, - primary_message_fragment=diagnostic_case.primary_message_fragment, - reference_message_fragment=diagnostic_case.primary_message_fragment, - ), - start_line=primary_position[0], - end_line=reference_position[0] - 1, - ) - - @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) - def test_get_diagnostics_for_symbol(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: - self._assert_diagnostics_for_symbol( - serena_agent, - diagnostic_case=diagnostic_case, - check_symbol_references=False, - ) - - @pytest.mark.parametrize("serena_agent,diagnostic_case", WORKING_DIAGNOSTIC_TOOL_CASE_PARAMS, indirect=["serena_agent"]) - def test_get_diagnostics_for_symbol_with_references(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: - self._assert_diagnostics_for_symbol( - serena_agent, - diagnostic_case=diagnostic_case, - check_symbol_references=True, - ) - - if IMPLEMENTATION_TOOL_TEST_CASES: - - @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,impl_file,expected_symbol_name", - IMPLEMENTATION_TOOL_TEST_CASES, - indirect=["serena_agent"], - ) - def test_find_symbol_implementations( - self, - serena_agent: SerenaAgent, - symbol_name: str, - def_file: str, - impl_file: str, - expected_symbol_name: str, - ) -> None: - self._assert_find_symbol_implementations(serena_agent, symbol_name, def_file, impl_file, expected_symbol_name) - - @pytest.mark.parametrize( - "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", - [ - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass", - False, - "NestedClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="exact_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass/find_me", - False, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="exact_qualname_method", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedCl", # Substring for NestedClass - True, - "NestedClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="substring_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "OuterClass/NestedClass/find_m", # Substring for find_me - True, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="substring_qualname_method", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/OuterClass", # Absolute path - False, - "OuterClass", - "Class", - os.path.join("test_repo", "nested.py"), - id="absolute_qualname_class", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/OuterClass/NestedClass/find_m", # Absolute path with substring - True, - "find_me", - "Method", - os.path.join("test_repo", "nested.py"), - id="absolute_substring_qualname_method", - marks=pytest.mark.python, - ), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_name_path( - self, - serena_agent, - name_path: str, - substring_matching: bool, - expected_symbol_name: str, - expected_kind: str, - expected_file: str, - ): + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_CASES, indirect=["serena_agent"]) + def test_find_symbol_name_path(self, serena_agent: SerenaAgent, case: FindSymbolNamePathCase) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.name_path, depth=0, relative_path=None, include_body=False, include_kinds=None, exclude_kinds=None, - substring_matching=substring_matching, + substring_matching=case.substring_matching, ) symbols = json.loads(result) assert any( - expected_symbol_name == s["name_path"].split("/")[-1] - and expected_kind.lower() in s["kind"].lower() - and expected_file in s["relative_path"] + case.expected_symbol_name == s["name_path"].split("/")[-1] + and case.expected_kind.lower() in s["kind"].lower() + and case.expected_file in s["relative_path"] for s in symbols - ), f"Expected to find {name_path} ({expected_kind}) in {expected_file}. Symbols: {symbols}" + ), f"Expected to find {case.name_path} ({case.expected_kind}) in {case.expected_file}. Symbols: {symbols}" - @pytest.mark.parametrize( - "serena_agent,name_path", - [ - pytest.param( - Language.PYTHON, - "/NestedClass", # Absolute path, NestedClass is not top-level - id="absolute_path_non_top_level_no_match", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON, - "/NoSuchParent/NestedClass", # Absolute path with non-existent parent - id="absolute_path_non_existent_parent_no_match", - marks=pytest.mark.python, - ), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_name_path_no_match( - self, - serena_agent, - name_path: str, - ): + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_NAME_PATH_NO_MATCH_CASES, indirect=["serena_agent"]) + def test_find_symbol_name_path_no_match(self, serena_agent: SerenaAgent, case: FindSymbolNoMatchCase) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.name_path, depth=0, substring_matching=True, ) symbols = json.loads(result) - assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}" + assert not symbols, f"Expected to find no symbols for {case.name_path}. Symbols found: {symbols}" - @pytest.mark.parametrize( - "serena_agent,name_path,num_expected", - [ - pytest.param( - Language.JAVA, - "Model/getName", - 2, - id="overloaded_java_method", - marks=pytest.mark.java, - ), - ], - indirect=["serena_agent"], - ) - def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, name_path: str, num_expected: int): + @pytest.mark.parametrize("serena_agent,case", FIND_SYMBOL_OVERLOADED_FUNCTION_CASES, indirect=["serena_agent"]) + def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, case: FindSymbolOverloadedCase) -> None: """ Tests whether the FindSymbolTool can find all overloads of a function/method (provided that the overload id remains unspecified in the name path) @@ -1032,51 +1111,38 @@ class TestSerenaAgent: find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply_ex( - name_path_pattern=name_path, + name_path_pattern=case.name_path, depth=0, substring_matching=False, ) symbols = json.loads(result) - assert len(symbols) == num_expected, ( - f"Expected to find {num_expected} symbols for overloaded function {name_path}. Symbols found: {symbols}" + assert len(symbols) == case.num_expected, ( + f"Expected to find {case.num_expected} symbols for overloaded function {case.name_path}. Symbols found: {symbols}" ) - @pytest.mark.parametrize( - "serena_agent,name_path,relative_path", - [ - pytest.param( - Language.JAVA, - "Model/getName", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - id="overloaded_java_method", - marks=pytest.mark.java, - ), - ], - indirect=["serena_agent"], - ) - def test_non_unique_symbol_reference_error(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + @pytest.mark.parametrize("serena_agent,case", NON_UNIQUE_SYMBOL_REFERENCE_ERROR_CASES, indirect=["serena_agent"]) + def test_non_unique_symbol_reference_error( + self, + serena_agent: SerenaAgent, + case: NonUniqueSymbolReferenceCase, + ) -> None: """ Tests whether the tools operating on a well-defined symbol raises an error when the symbol reference is non-unique. We exemplarily test a retrieval tool (FindReferencingSymbolsTool) and an editing tool (ReplaceSymbolBodyTool). """ - match_text = "multiple" - find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) - with pytest.raises(ValueError, match=match_text): - find_refs_tool.apply(name_path=name_path, relative_path=relative_path) + with pytest.raises(ValueError, match=case.expected_error_fragment): + find_refs_tool.apply(name_path=case.name_path, relative_path=case.relative_path) replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) - with pytest.raises(ValueError, match=match_text): - replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="") + with pytest.raises(ValueError, match=case.expected_error_fragment): + replace_symbol_body_tool.apply(name_path=case.name_path, relative_path=case.relative_path, body="") @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_unique_regex"), ], indirect=["serena_agent"], ) @@ -1089,7 +1155,7 @@ class TestSerenaAgent: replace_content_tool = serena_agent.get_tool(ReplaceContentTool) result = replace_content_tool.apply( needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}', - repl='catch(error) { console.log("Never mind"); }', + repl='catch(error) {console.log("Never mind"); }', relative_path=relative_path, mode="regex", ) @@ -1098,14 +1164,11 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_backslashes"), ], indirect=["serena_agent"], ) - @pytest.mark.parametrize("mode", ["literal", "regex"]) + @pytest.mark.parametrize("mode", ["literal", "regex"], ids=["literal_mode", "regex_mode"]) def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]): """ Tests a content replacement where the needle and replacement strings contain backslashes. @@ -1129,8 +1192,8 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param(Language.PYTHON, marks=pytest.mark.python), - pytest.param(Language.PYTHON_TY, marks=pytest.mark.python), + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_services"), + pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_services"), ], indirect=["serena_agent"], ) @@ -1138,6 +1201,7 @@ class TestSerenaAgent: """Tests that file-level edits report newly introduced diagnostics.""" relative_path = os.path.join("test_repo", "services.py") replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + replace_content_tool._ENABLE_DIAGNOSTICS = True with project_file_modification_context(serena_agent, relative_path): result = replace_content_tool.apply( @@ -1156,8 +1220,8 @@ class TestSerenaAgent: @pytest.mark.parametrize( "serena_agent", [ - pytest.param(Language.PYTHON, marks=pytest.mark.python), - pytest.param(Language.PYTHON_TY, marks=pytest.mark.python), + pytest.param(Language.PYTHON, marks=get_pytest_markers(Language.PYTHON), id="python_container_body"), + pytest.param(Language.PYTHON_TY, marks=get_pytest_markers(Language.PYTHON_TY), id="python_ty_container_body"), ], indirect=["serena_agent"], ) @@ -1165,6 +1229,7 @@ class TestSerenaAgent: """Tests that symbol-level edits report newly introduced diagnostics.""" relative_path = os.path.join("test_repo", "services.py") replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) + replace_symbol_body_tool._ENABLE_DIAGNOSTICS = True with project_file_modification_context(serena_agent, relative_path): result = replace_symbol_body_tool.apply( @@ -1185,10 +1250,7 @@ def create_service_container() -> dict[str, Any]: @pytest.mark.parametrize( "serena_agent", [ - pytest.param( - Language.TYPESCRIPT, - marks=pytest.mark.typescript, - ), + pytest.param(Language.TYPESCRIPT, marks=get_pytest_markers(Language.TYPESCRIPT), id="typescript_ambiguous_regex"), ], indirect=["serena_agent"], ) @@ -1201,98 +1263,40 @@ def create_service_container() -> dict[str, Any]: with pytest.raises(ValueError, match="ambiguous"): replace_content_tool.apply( needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}', - repl='catch(error) { console.log("Never mind"); }', + repl='catch(error) {console.log("Never mind"); }', relative_path="ws_manager.js", mode="regex", ) - @pytest.mark.parametrize( - "serena_agent,name_path,relative_path", - [ - pytest.param( - Language.PYTHON, - "User", - os.path.join("test_repo", "models.py"), - marks=pytest.mark.python, - ), - pytest.param( - Language.JAVA, - "Model", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "Model", - os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.TYPESCRIPT, - "helperFunction", - "index.ts", - marks=pytest.mark.typescript, - ), - ], - indirect=["serena_agent"], - ) - def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_BLOCKED_CASES, indirect=["serena_agent"]) + def test_safe_delete_symbol_blocked_by_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase): """ Tests that SafeDeleteSymbol refuses to delete a symbol that is referenced elsewhere and returns a message listing the referencing files. """ # wrap in modification context as a safety net: if the tool has a bug and deletes anyway, # the file will be restored, preventing corruption of test resources - with project_file_modification_context(serena_agent, relative_path): + with project_file_modification_context(serena_agent, case.relative_path): safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol) - result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path) + result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path) assert "Cannot delete" in result, f"Expected deletion to be blocked due to existing references, but got: {result}" assert "referenced in" in result, f"Expected reference information in result, but got: {result}" - @pytest.mark.parametrize( - "serena_agent,name_path,relative_path", - [ - pytest.param( - Language.PYTHON, - "Timer", - os.path.join("test_repo", "utils.py"), - marks=pytest.mark.python, - ), - pytest.param( - Language.JAVA, - "ModelUser", - os.path.join("src", "main", "java", "test_repo", "ModelUser.java"), - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - "ModelUser", - os.path.join("src", "main", "kotlin", "test_repo", "ModelUser.kt"), - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.TYPESCRIPT, - "unusedStandaloneFunction", - "index.ts", - marks=pytest.mark.typescript, - ), - ], - indirect=["serena_agent"], - ) - def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + @pytest.mark.parametrize("serena_agent,case", SAFE_DELETE_SUCCEEDS_CASES, indirect=["serena_agent"]) + def test_safe_delete_symbol_succeeds_when_no_references(self, serena_agent: SerenaAgent, case: SafeDeleteCase): """ Tests that SafeDeleteSymbol successfully deletes a symbol that has no references and that the symbol is actually removed from the file. """ - with project_file_modification_context(serena_agent, relative_path): + with project_file_modification_context(serena_agent, case.relative_path): safe_delete_tool = serena_agent.get_tool(SafeDeleteSymbol) - result = safe_delete_tool.apply(name_path_pattern=name_path, relative_path=relative_path) + result = safe_delete_tool.apply(name_path_pattern=case.name_path, relative_path=case.relative_path) assert result == SUCCESS_RESULT, f"Expected successful deletion, but got: {result}" # verify the symbol was actually removed from the file - file_content = read_project_file(serena_agent.get_active_project(), relative_path) - assert name_path not in file_content, ( - f"Expected symbol {name_path} to be removed from {relative_path}, but it still appears in the file content" + file_content = read_project_file(serena_agent.get_active_project(), case.relative_path) + assert case.name_path not in file_content, ( + f"Expected symbol {case.name_path} to be removed from {case.relative_path}, but it still appears in the file content" ) diff --git a/test/solidlsp/test_defining_symbol_matrix.py b/test/solidlsp/test_defining_symbol_matrix.py deleted file mode 100644 index c0509a2f..00000000 --- a/test/solidlsp/test_defining_symbol_matrix.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -import shutil -from pathlib import Path - -import pytest - -from solidlsp import SolidLanguageServer -from solidlsp.ls_config import Language -from test.conftest import find_identifier_occurrence_position, is_ci -from test.solidlsp import clojure as clj - - -@pytest.mark.parametrize( - "language_server,relative_path,identifier,occurrence_index,column_offset,expected_name,expected_definition_file", - [ - pytest.param( - Language.PYTHON, - os.path.join("test_repo", "services.py"), - "User", - 1, - 1, - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param( - Language.PYTHON_TY, - os.path.join("test_repo", "services.py"), - "User", - 1, - 1, - "User", - "models.py", - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "main.go", "Helper", 0, 1, "Helper", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - os.path.join("src", "main", "java", "test_repo", "Main.java"), - "Model", - 0, - 1, - "Model", - "Model.java", - marks=pytest.mark.java, - ), - pytest.param( - Language.KOTLIN, - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), - "Model", - 0, - 1, - "Model", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param( - Language.RUST, - os.path.join("src", "main.rs"), - "format_greeting", - 0, - 1, - "format_greeting", - "lib.rs", - marks=pytest.mark.rust, - ), - pytest.param(Language.PHP, "index.php", "helperFunction", 0, 5, "helperFunction", "helper.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - clj.UTILS_PATH, - "multiply", - 0, - 1, - "multiply", - clj.CORE_PATH, - marks=[ - pytest.mark.clojure, - pytest.mark.skipif(not clj.is_clojure_cli_available(), reason="clojure CLI is not installed"), - ], - ), - pytest.param(Language.CSHARP, "Program.cs", "Add", 0, 1, "Add", "Program.cs", marks=pytest.mark.csharp), - pytest.param( - Language.POWERSHELL, - "main.ps1", - "Convert-ToUpperCase", - 0, - 1, - "function Convert-ToUpperCase ()", - "utils.ps1", - marks=pytest.mark.powershell, - ), - pytest.param(Language.CPP_CCLS, "a.cpp", "add", 0, 1, "add", "b.cpp", marks=pytest.mark.cpp), - pytest.param( - Language.LEAN4, - "Main.lean", - "add", - 0, - 1, - "add", - "Helper.lean", - marks=[ - pytest.mark.lean4, - pytest.mark.skipif(shutil.which("lean") is None, reason="Lean is not installed"), - pytest.mark.xfail(reason="Lean4 LS does not reliably resolve cross-file defining symbols in CI"), - ], - ), - pytest.param(Language.TYPESCRIPT, "index.ts", "helperFunction", 1, 1, "helperFunction", "index.ts", marks=pytest.mark.typescript), - pytest.param( - Language.FSHARP, - "Program.fs", - "add", - 0, - 1, - "add", - "Calculator.fs", - marks=[pytest.mark.fsharp, pytest.mark.xfail(reason="F# language server cannot reliably resolve defining symbols")], - ), - ], - indirect=["language_server"], -) -def test_request_defining_symbol_matrix( - language_server: SolidLanguageServer, - relative_path: str, - identifier: str, - occurrence_index: int, - column_offset: int, - expected_name: str, - expected_definition_file: str, -) -> None: - repo_root = Path(language_server.repository_root_path) - position = find_identifier_occurrence_position(repo_root / relative_path, identifier, occurrence_index, column_offset) - assert position is not None, f"Could not find occurrence {occurrence_index} of {identifier!r} in {relative_path}" - - defining_symbol = language_server.request_defining_symbol(relative_path, *position) - assert defining_symbol is not None, f"Expected a defining symbol for {identifier!r} in {relative_path}" - assert defining_symbol.get("name") == expected_name, f"Expected defining symbol name {expected_name!r}, got: {defining_symbol}" - assert expected_definition_file in defining_symbol["location"].get("relativePath", ""), ( - f"Expected defining symbol in {expected_definition_file!r}, got: {defining_symbol}" - ) diff --git a/test/solidlsp/test_diagnostics_matrix.py b/test/solidlsp/test_diagnostics_matrix.py deleted file mode 100644 index 2d1b50fe..00000000 --- a/test/solidlsp/test_diagnostics_matrix.py +++ /dev/null @@ -1,79 +0,0 @@ -from pathlib import Path - -import pytest - -from serena.symbol import LanguageServerSymbolRetriever -from solidlsp import SolidLanguageServer -from test.conftest import find_identifier_position -from test.diagnostics_cases import DIAGNOSTIC_CASE_PARAMS, DiagnosticCase - - -@pytest.mark.parametrize("language_server,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["language_server"]) -def test_request_text_document_diagnostics_matrix( - language_server: SolidLanguageServer, - diagnostic_case: DiagnosticCase, -) -> None: - diagnostics = language_server.request_text_document_diagnostics(diagnostic_case.relative_path, min_severity=1) - assert diagnostics, f"Expected diagnostics for {diagnostic_case.language.value}:{diagnostic_case.relative_path}" - - diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] - assert any(diagnostic_case.primary_message_fragment in message for message in diagnostic_messages), diagnostic_messages - assert any(diagnostic_case.reference_message_fragment in message for message in diagnostic_messages), diagnostic_messages - - repo_root = language_server.repository_root_path - primary_symbol_position = find_identifier_position( - Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.primary_symbol_identifier - ) - reference_symbol_position = find_identifier_position( - Path(repo_root) / diagnostic_case.relative_path, diagnostic_case.reference_symbol_identifier - ) - assert primary_symbol_position is not None - assert reference_symbol_position is not None - - primary_diagnostics = language_server.request_text_document_diagnostics( - diagnostic_case.relative_path, - start_line=primary_symbol_position[0], - end_line=reference_symbol_position[0] - 1, - min_severity=1, - ) - primary_messages = [diagnostic["message"] for diagnostic in primary_diagnostics] - assert primary_messages, f"Expected range-filtered diagnostics for {diagnostic_case.primary_symbol_identifier}" - assert any(diagnostic_case.primary_message_fragment in message for message in primary_messages), primary_messages - assert all(diagnostic_case.reference_message_fragment not in message for message in primary_messages), primary_messages - - -@pytest.mark.parametrize("project_with_ls,diagnostic_case", DIAGNOSTIC_CASE_PARAMS, indirect=["project_with_ls"]) -def test_get_symbol_diagnostics_matrix(project_with_ls, diagnostic_case: DiagnosticCase) -> None: - symbol_retriever = LanguageServerSymbolRetriever(project_with_ls) - - diagnostics_by_symbol = symbol_retriever.get_symbol_diagnostics( - diagnostic_case.primary_symbol_name_path, - reference_file=diagnostic_case.relative_path, - min_severity=1, - ) - diagnostic_messages_by_symbol = { - symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics] - for symbol, diagnostics in diagnostics_by_symbol.items() - } - assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_by_symbol, diagnostic_messages_by_symbol - assert any( - diagnostic_case.primary_message_fragment in message - for message in diagnostic_messages_by_symbol[diagnostic_case.primary_symbol_name_path] - ), diagnostic_messages_by_symbol - - diagnostics_with_references = symbol_retriever.get_symbol_diagnostics( - diagnostic_case.primary_symbol_name_path, - reference_file=diagnostic_case.relative_path, - check_symbol_references=True, - min_severity=1, - ) - diagnostic_messages_with_references = { - symbol.get_name_path(): [diagnostic["message"] for diagnostic in diagnostics] - for symbol, diagnostics in diagnostics_with_references.items() - } - assert diagnostic_case.primary_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references - assert diagnostic_case.reference_symbol_name_path in diagnostic_messages_with_references, diagnostic_messages_with_references - assert any( - diagnostic_case.reference_message_fragment in message - for message in diagnostic_messages_with_references[diagnostic_case.reference_symbol_name_path] - ), diagnostic_messages_with_references From d198d67a9f6d0ae7898cdaddf42e057a7bc3187e Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 28 Apr 2026 21:07:01 +0200 Subject: [PATCH 35/48] Added JetBrains diagnostics tools, WIP --- .../jetbrains/jetbrains_plugin_client.py | 48 ++++++++++++ src/serena/jetbrains/jetbrains_types.py | 27 +++++++ .../config/internal_modes/jetbrains.yml | 2 + src/serena/tools/jetbrains_tools.py | 73 +++++++++++++++++++ 4 files changed, 150 insertions(+) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 263bc8a9..c662ec76 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -661,6 +661,54 @@ class JetBrainsPluginClient(ToStringMixin): } return self._make_request("POST", "/debugReplClose", request_data) + def run_inspections( + self, + relative_path: str, + min_severity: str | None = None, + inspection_names: list[str] | None = None, + start_line: int | None = None, + end_line: int | None = None, + ) -> jb.RunInspectionsResponse: + """ + Runs IDE inspections on the given file and returns the results. + + :param relative_path: the relative path to the file to inspect + :param min_severity: minimum severity level to include (e.g. "WARNING", "ERROR") + :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 + """ + request_data: dict[str, Any] = { + "relativePath": relative_path, + } + if min_severity is not None: + request_data["minSeverity"] = min_severity + if inspection_names is not None: + request_data["inspectionNames"] = inspection_names + if start_line is not None: + request_data["startLine"] = start_line + if end_line is not None: + request_data["endLine"] = end_line + return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspections", request_data)) + + def list_inspections( + self, + language: str | None = None, + group_path_contains: str | None = None, + ) -> jb.ListInspectionsResponse: + """ + Lists available IDE inspections, optionally filtered by language and/or group path. + + :param language: optional language to filter inspections by (e.g. "Java", "Python") + :param group_path_contains: optional substring to filter inspection group paths + """ + request_data: dict[str, Any] = {} + if language is not None: + request_data["language"] = language + if group_path_contains is not None: + request_data["groupPathContains"] = group_path_contains + return cast(jb.ListInspectionsResponse, self._make_request("POST", "/listInspections", request_data)) + def close(self) -> None: self._session.close() diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index ead4eb55..ce488ea2 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -89,3 +89,30 @@ class TypeHierarchyNodeDTO(TypedDict): class TypeHierarchyResponse(TypedDict): hierarchy: NotRequired[list[TypeHierarchyNodeDTO]] 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 + language: NotRequired[str] + description: NotRequired[str] + + +class ListInspectionsResponse(TypedDict): + inspections: list[InspectionInfoDTO] diff --git a/src/serena/resources/config/internal_modes/jetbrains.yml b/src/serena/resources/config/internal_modes/jetbrains.yml index 34baf6ce..1932906c 100644 --- a/src/serena/resources/config/internal_modes/jetbrains.yml +++ b/src/serena/resources/config/internal_modes/jetbrains.yml @@ -25,3 +25,5 @@ included_optional_tools: - jet_brains_safe_delete - jet_brains_debug - serena_info + - jet_brains_run_inspections + - jet_brains_list_inspections diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 0664fb03..74a05359 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -595,3 +595,76 @@ class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta): else: response = client.debug_close(repl_key=repl_key) return response.get("result", str(response)) + + +class JetBrainsRunInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Runs JetBrains IDE inspections on a file and returns the results. + """ + + def apply( + self, + relative_path: str, + min_severity: str | None = None, + inspection_names: list[str] | None = None, + start_line: int | None = None, + end_line: int | None = None, + max_answer_chars: int = -1, + ) -> str: + """ + Runs IDE inspections (code analysis) on the given file and returns the problems found. + This leverages the full power of JetBrains' static analysis engine, including language-specific + inspections, type checking, potential bugs, code style issues, and more. + + :param relative_path: the relative path to the file to inspect. + :param min_severity: minimum severity level to include in results (e.g. "ERROR", "WARNING", "WEAK_WARNING", "INFO"). + If not specified, all severities are returned. + :param inspection_names: optional list of specific inspection names to run (e.g. ["UnusedImport", "TypeMismatch"]). + If not specified, all applicable inspections are run. + :param start_line: optional 1-based start line to restrict the inspection range. + :param end_line: optional 1-based end line to restrict the inspection range. + :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. + -1 means the default value from the config will be used. + :return: JSON string with inspection results including severity, message, and location. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.run_inspections( + relative_path=relative_path, + min_severity=min_severity, + inspection_names=inspection_names, + start_line=start_line, + end_line=end_line, + ) + result = self._to_json(response_dict) + return self._limit_length(result, max_answer_chars) + + +class JetBrainsListInspectionsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): + """ + Lists available JetBrains IDE inspections, optionally filtered by language or group. + """ + + def apply( + self, + language: str | None = None, + group_path_contains: str | None = None, + max_answer_chars: int = -1, + ) -> str: + """ + Lists the available IDE inspections. Use this to discover which inspections can be passed + to the run_inspections tool's `inspection_names` parameter. + + :param language: optional language to filter by (e.g. "Java", "Python", "Kotlin"). + :param group_path_contains: optional substring to match against the inspection group path + (e.g. "probable bugs", "code style"). + :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. + -1 means the default value from the config will be used. + :return: JSON string with the list of available inspections including name, group path, and language. + """ + with JetBrainsPluginClient.from_project(self.project) as client: + response_dict = client.list_inspections( + language=language, + group_path_contains=group_path_contains, + ) + result = self._to_json(response_dict) + return self._limit_length(result, max_answer_chars) From bd7e64e85a04751f629a2e4094575e0b6e67aa37 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 28 Apr 2026 22:06:41 +0200 Subject: [PATCH 36/48] Fixed diagnostics files uri handling, added low-level tests --- .gitignore | 4 +- .serena/project.yml | 1 + scripts/demo_run_tools.py | 6 +- .../jetbrains/jetbrains_plugin_client.py | 2 +- .../language_servers/elm_language_server.py | 28 +++++-- .../fortran_language_server.py | 1 + .../language_servers/lean4_language_server.py | 1 + .../language_servers/pascal_server.py | 9 +++ .../solidity_language_server.py | 1 + .../typescript_language_server.py | 26 +++++- .../language_servers/vue_language_server.py | 13 +++ src/solidlsp/language_servers/zls.py | 1 + src/solidlsp/ls.py | 79 ++++++++++++++++--- .../bash/test_repo/diagnostics_sample.sh | 4 + .../test_repo/src/diagnostics_sample.cr | 5 ++ .../test_repo/lib/diagnostics_sample.dart | 3 + .../test_repo/lib/diagnostics_sample.ex | 10 +++ .../repos/elm/test_repo/DiagnosticsSample.elm | 5 ++ .../test_repo/src/diagnostics_sample.erl | 5 ++ .../fortran/test_repo/diagnostics_sample.f90 | 5 ++ .../com/example/DiagnosticsSample.groovy | 13 +++ .../test_repo/src/DiagnosticsSample.hs | 7 ++ .../haxe/test_repo/src/DiagnosticsSample.hx | 10 +++ .../hlsl/test_repo/diagnostics_sample.hlsl | 3 + .../json/test_repo/diagnostics_sample.json | 4 + .../julia/test_repo/diagnostics_sample.jl | 3 + .../lean4/test_repo/DiagnosticsSample.lean | 5 -- .../matlab/test_repo/diagnostics_sample.m | 3 + .../nix/test_repo/diagnostics_sample.nix | 4 + .../ocaml/test_repo/lib/diagnostics_sample.ml | 1 + test/resources/repos/ocaml/test_repo/lib/dune | 3 +- .../pascal/test_repo/diagnostics_sample.pas | 4 + .../perl/test_repo/diagnostics_sample.pl | 5 ++ .../test_repo/diagnostics_sample.ps1 | 9 +-- .../scala/com/example/DiagnosticsSample.scala | 9 +++ .../test_repo/contracts/DiagnosticsSample.sol | 7 ++ .../repos/swift/test_repo/Package.swift | 5 +- .../test_repo/src/diagnostics_sample.swift | 3 + .../test_repo/diagnostics_sample.sv | 5 ++ .../terraform/test_repo/diagnostics_sample.tf | 4 + .../toml/test_repo/diagnostics_sample.toml | 2 + .../vue/test_repo/src/DiagnosticsSample.vue | 3 + .../yaml/test_repo/diagnostics_sample.yaml | 5 ++ test/resources/repos/zig/test_repo/build.zig | 2 +- .../zig/test_repo/src/diagnostics_sample.zig | 3 + .../repos/zig/test_repo/src/main.zig | 2 +- .../__snapshots__/test_symbol_editing.ambr | 26 +++--- test/solidlsp/bash/test_bash_diagnostics.py | 17 ++++ test/solidlsp/clojure/__init__.py | 4 +- .../clojure/test_clojure_diagnostics.py | 19 +++++ test/solidlsp/conftest.py | 9 +++ test/solidlsp/cpp/test_cpp_diagnostics.py | 17 ++++ .../crystal/test_crystal_diagnostics.py | 23 ++++++ .../csharp/test_csharp_diagnostics.py | 17 ++++ test/solidlsp/dart/test_dart_diagnostics.py | 17 ++++ test/solidlsp/elixir/test_elixir_basic.py | 10 +++ test/solidlsp/elm/test_elm_diagnostics.py | 20 +++++ test/solidlsp/erlang/test_erlang_basic.py | 10 +++ .../fortran/test_fortran_diagnostics.py | 17 ++++ test/solidlsp/fsharp/test_fsharp_basic.py | 23 ++---- test/solidlsp/go/test_go_diagnostics.py | 17 ++++ test/solidlsp/groovy/test_groovy_basic.py | 10 +++ test/solidlsp/haskell/test_haskell_basic.py | 10 +++ test/solidlsp/haxe/test_haxe_diagnostics.py | 17 ++++ test/solidlsp/hlsl/test_hlsl_diagnostics.py | 17 ++++ test/solidlsp/java/test_java_diagnostics.py | 17 ++++ .../solidlsp/json_ls/test_json_diagnostics.py | 17 ++++ test/solidlsp/julia/test_julia_basic.py | 10 +++ .../kotlin/test_kotlin_diagnostics.py | 17 ++++ test/solidlsp/lean4/test_lean4_diagnostics.py | 17 ++++ test/solidlsp/matlab/test_matlab_basic.py | 10 +++ test/solidlsp/nix/test_nix_basic.py | 10 +++ test/solidlsp/ocaml/test_ocaml_diagnostics.py | 17 ++++ .../pascal/test_pascal_diagnostics.py | 25 ++++++ test/solidlsp/perl/test_perl_basic.py | 10 +++ test/solidlsp/php/test_php_diagnostics.py | 17 ++++ .../powershell/test_powershell_diagnostics.py | 17 ++++ .../python/test_python_diagnostics.py | 19 +++++ test/solidlsp/rust/test_rust_basic.py | 2 +- test/solidlsp/rust/test_rust_diagnostics.py | 17 ++++ .../scala/test_scala_language_server.py | 10 +++ .../solidity/test_solidity_diagnostics.py | 24 ++++++ test/solidlsp/swift/test_swift_basic.py | 10 +++ .../test_systemverilog_diagnostics.py | 17 ++++ .../terraform/test_terraform_diagnostics.py | 17 ++++ test/solidlsp/toml/test_toml_diagnostics.py | 17 ++++ .../typescript/test_typescript_diagnostics.py | 17 ++++ test/solidlsp/util/diagnostics.py | 19 +++++ test/solidlsp/vue/test_vue_diagnostics.py | 17 ++++ .../solidlsp/yaml_ls/test_yaml_diagnostics.py | 17 ++++ test/solidlsp/zig/test_zig_basic.py | 10 +++ 91 files changed, 958 insertions(+), 72 deletions(-) create mode 100644 test/resources/repos/bash/test_repo/diagnostics_sample.sh create mode 100644 test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr create mode 100644 test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart create mode 100644 test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex create mode 100644 test/resources/repos/elm/test_repo/DiagnosticsSample.elm create mode 100644 test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl create mode 100644 test/resources/repos/fortran/test_repo/diagnostics_sample.f90 create mode 100644 test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy create mode 100644 test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs create mode 100644 test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx create mode 100644 test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl create mode 100644 test/resources/repos/json/test_repo/diagnostics_sample.json create mode 100644 test/resources/repos/julia/test_repo/diagnostics_sample.jl create mode 100644 test/resources/repos/matlab/test_repo/diagnostics_sample.m create mode 100644 test/resources/repos/nix/test_repo/diagnostics_sample.nix create mode 100644 test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml create mode 100644 test/resources/repos/pascal/test_repo/diagnostics_sample.pas create mode 100644 test/resources/repos/perl/test_repo/diagnostics_sample.pl create mode 100644 test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala create mode 100644 test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol create mode 100644 test/resources/repos/swift/test_repo/src/diagnostics_sample.swift create mode 100644 test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv create mode 100644 test/resources/repos/terraform/test_repo/diagnostics_sample.tf create mode 100644 test/resources/repos/toml/test_repo/diagnostics_sample.toml create mode 100644 test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue create mode 100644 test/resources/repos/yaml/test_repo/diagnostics_sample.yaml create mode 100644 test/resources/repos/zig/test_repo/src/diagnostics_sample.zig create mode 100644 test/solidlsp/bash/test_bash_diagnostics.py create mode 100644 test/solidlsp/clojure/test_clojure_diagnostics.py create mode 100644 test/solidlsp/cpp/test_cpp_diagnostics.py create mode 100644 test/solidlsp/crystal/test_crystal_diagnostics.py create mode 100644 test/solidlsp/csharp/test_csharp_diagnostics.py create mode 100644 test/solidlsp/dart/test_dart_diagnostics.py create mode 100644 test/solidlsp/elm/test_elm_diagnostics.py create mode 100644 test/solidlsp/fortran/test_fortran_diagnostics.py create mode 100644 test/solidlsp/go/test_go_diagnostics.py create mode 100644 test/solidlsp/haxe/test_haxe_diagnostics.py create mode 100644 test/solidlsp/hlsl/test_hlsl_diagnostics.py create mode 100644 test/solidlsp/java/test_java_diagnostics.py create mode 100644 test/solidlsp/json_ls/test_json_diagnostics.py create mode 100644 test/solidlsp/kotlin/test_kotlin_diagnostics.py create mode 100644 test/solidlsp/lean4/test_lean4_diagnostics.py create mode 100644 test/solidlsp/ocaml/test_ocaml_diagnostics.py create mode 100644 test/solidlsp/pascal/test_pascal_diagnostics.py create mode 100644 test/solidlsp/php/test_php_diagnostics.py create mode 100644 test/solidlsp/powershell/test_powershell_diagnostics.py create mode 100644 test/solidlsp/python/test_python_diagnostics.py create mode 100644 test/solidlsp/rust/test_rust_diagnostics.py create mode 100644 test/solidlsp/solidity/test_solidity_diagnostics.py create mode 100644 test/solidlsp/systemverilog/test_systemverilog_diagnostics.py create mode 100644 test/solidlsp/terraform/test_terraform_diagnostics.py create mode 100644 test/solidlsp/toml/test_toml_diagnostics.py create mode 100644 test/solidlsp/typescript/test_typescript_diagnostics.py create mode 100644 test/solidlsp/util/diagnostics.py create mode 100644 test/solidlsp/vue/test_vue_diagnostics.py create mode 100644 test/solidlsp/yaml_ls/test_yaml_diagnostics.py diff --git a/.gitignore b/.gitignore index dbc94920..37c1649c 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,8 @@ downloads/ eggs/ .eggs/ lib/ +!test/resources/repos/dart/test_repo/lib/ +!test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart lib64/ parts/ sdist/ @@ -271,4 +273,4 @@ cabal.project.local* zz-misc/ vue-implementation/ -news/news.json \ No newline at end of file +news/news.json diff --git a/.serena/project.yml b/.serena/project.yml index 84b6fcf8..fbaead68 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -28,6 +28,7 @@ project_name: "serena" languages: - python - typescript +- clojure # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index b0ed063b..4914201c 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -16,6 +16,7 @@ from serena.tools import ( JetBrainsFindSymbolTool, JetBrainsGetSymbolsOverviewTool, JetBrainsInlineSymbol, + JetBrainsRunInspectionsTool, JetBrainsSafeDeleteTool, SearchForPatternTool, ) @@ -35,12 +36,13 @@ if __name__ == "__main__": overview_tool = agent.get_tool(JetBrainsGetSymbolsOverviewTool) safe_delete_tool = agent.get_tool(JetBrainsSafeDeleteTool) inline_symbol = agent.get_tool(JetBrainsInlineSymbol) + diagnostics_in_file_tool = agent.get_tool(GetDiagnosticsForFileTool) + jb_inspections_tool = agent.get_tool(JetBrainsRunInspectionsTool) result = agent.execute_task( lambda: overview_tool.apply( # name_path_pattern="SerenaAgent", - relative_path="src/serena/agent.py", - depth=2, + relative_path="test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj", # keep_definition=True, ) ) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index c662ec76..c7b440ce 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -689,7 +689,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", "/runInspections", request_data)) + return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data)) def list_inspections( self, diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index e1960c32..0c6139c1 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -32,9 +32,10 @@ class ElmLanguageServer(SolidLanguageServer): Creates an ElmLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ elm_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + managed_bin_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "elm-lsp", "node_modules", ".bin") # Resolve ELM_HOME to absolute path if it's set to a relative path - env = {} + env = {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} elm_home = os.environ.get("ELM_HOME") if elm_home: if not os.path.isabs(elm_home): @@ -65,9 +66,15 @@ class ElmLanguageServer(SolidLanguageServer): """ Setup runtime dependencies for Elm Language Server and return the command to start the server. """ - # Check if elm-language-server is already installed globally + elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM) + elm_language_server_version = elm_config.get("elm_language_server_version", "2.8.0") + elm_compiler_version = elm_config.get("elm_compiler_version", "0.19.1-6") + npm_registry = elm_config.get("npm_registry") + + # Check if elm-language-server and elm are already installed globally system_elm_ls = shutil.which("elm-language-server") - if system_elm_ls: + system_elm = shutil.which("elm") + if system_elm_ls and system_elm: log.info(f"Found system-installed elm-language-server at {system_elm_ls}") return [system_elm_ls, "--stdio"] @@ -76,10 +83,6 @@ class ElmLanguageServer(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM) - elm_language_server_version = elm_config.get("elm_language_server_version", "2.8.0") - npm_registry = elm_config.get("npm_registry") - deps = RuntimeDependencyCollection( [ RuntimeDependency( @@ -88,6 +91,12 @@ class ElmLanguageServer(SolidLanguageServer): command=build_npm_install_command("@elm-tooling/elm-language-server", elm_language_server_version, npm_registry), platform_id="any", ), + RuntimeDependency( + id="elm", + description="Elm compiler package", + command=build_npm_install_command("elm", elm_compiler_version, npm_registry), + platform_id="any", + ), ] ) @@ -98,6 +107,10 @@ class ElmLanguageServer(SolidLanguageServer): log.info(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...") with LogTime("Installation of Elm language server dependencies", logger=log): deps.install(elm_ls_dir) + elif not system_elm: + log.info("Elm compiler not found on PATH. Installing a managed Elm compiler...") + with LogTime("Installation of Elm compiler dependency", logger=log): + deps.install(elm_ls_dir) if not os.path.exists(elm_ls_executable_path): raise FileNotFoundError( @@ -128,6 +141,7 @@ class ElmLanguageServer(SolidLanguageServer): "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 2cd6b099..a1d5d56d 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -220,6 +220,7 @@ class FortranLanguageServer(SolidLanguageServer): "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/lean4_language_server.py b/src/solidlsp/language_servers/lean4_language_server.py index 463e4992..961a65b6 100644 --- a/src/solidlsp/language_servers/lean4_language_server.py +++ b/src/solidlsp/language_servers/lean4_language_server.py @@ -124,6 +124,7 @@ class Lean4LanguageServer(SolidLanguageServer): "documentationFormat": ["markdown", "plaintext"], }, }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py index 9de2a926..1f2b7de7 100644 --- a/src/solidlsp/language_servers/pascal_server.py +++ b/src/solidlsp/language_servers/pascal_server.py @@ -777,6 +777,14 @@ class PascalLanguageServer(SolidLanguageServer): if value: initialization_options[var] = value + initialization_options.update( + { + "checkSyntax": True, + "publishDiagnostics": True, + "showSyntaxErrors": True, + } + ) + initialize_params = { "locale": "en", "capabilities": { @@ -831,6 +839,7 @@ class PascalLanguageServer(SolidLanguageServer): }, "formatting": {"dynamicRegistration": True}, "rangeFormatting": {"dynamicRegistration": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 223a6683..1e7ccd2e 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -148,6 +148,7 @@ class SolidityLanguageServer(SolidLanguageServer): "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], # type: ignore[list-item] }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 32e9ac22..8b1c04e8 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -89,7 +89,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): self.server_ready = threading.Event() self.initialize_searcher_command_available = threading.Event() - # Progress tracking for $/progress notifications (project indexing, etc.) + # tracking asynchronous diagnostics publication + self._published_diagnostics_timeout = 5.0 + + # tracking project indexing progress self._progress_lock = threading.Lock() self._active_progress_tokens: set[str] = set() self._indexing_complete = threading.Event() @@ -243,9 +246,11 @@ class TypeScriptLanguageServer(SolidLanguageServer): "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, + "configuration": True, "didChangeConfiguration": {"dynamicRegistration": True}, "symbol": {"dynamicRegistration": True}, }, @@ -293,6 +298,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): def execute_client_command_handler(params: dict) -> list: return [] + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + def do_nothing(params: dict) -> None: return @@ -352,6 +361,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): self._indexing_complete.set() self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", configuration_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_request("window/workDoneProgress/create", work_done_progress_create) @@ -397,6 +407,20 @@ class TypeScriptLanguageServer(SolidLanguageServer): self.INDEXING_PROGRESS_TIMEOUT, ) + @override + def _get_published_diagnostics_uri(self, request_uri: str) -> str: + if os.name != "nt" or not request_uri.startswith("file:///"): + return request_uri + + path_part = request_uri[len("file:///") :] + if len(path_part) >= 2 and path_part[0].isalpha() and path_part[1] == ":": + return f"file:///{path_part[0].lower()}%3A{path_part[2:]}" + return request_uri + + @override + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + return self._published_diagnostics_timeout + @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 2 diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index 06123623..f99056ae 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -426,6 +426,18 @@ class VueLanguageServer(SolidLanguageServer): with self._ts_server.open_file(relative_file_path): return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) + @override + 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]: + self._ensure_ls_operational() + assert self._ts_server is not None + return self._ts_server.request_text_document_diagnostics(relative_file_path, start_line, end_line, min_severity) + def _forward_edit_to_ts_server_if_needed(self, relative_file_path: str, edit_fn: Callable[[], object]) -> None: """ Calls ``edit_fn`` on the TypeScript server if the file is open there. @@ -598,6 +610,7 @@ class VueLanguageServer(SolidLanguageServer): "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py index 368f57eb..cb5652fc 100644 --- a/src/solidlsp/language_servers/zls.py +++ b/src/solidlsp/language_servers/zls.py @@ -132,6 +132,7 @@ class ZigLanguageServer(SolidLanguageServer): "dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"], }, + "publishDiagnostics": {"relatedInformation": True}, }, "workspace": { "workspaceFolders": True, diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 546c6a9f..4ea04694 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -13,7 +13,7 @@ from collections.abc import Hashable, Iterator from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath -from time import perf_counter, sleep +from time import monotonic, perf_counter, sleep from typing import Any, Self, Union, cast import pathspec @@ -754,21 +754,77 @@ class SolidLanguageServer(ABC): :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_published_diagnostics(uri=uri, after_generation=after_generation, timeout=timeout) - - # falling back to cached diagnostics - if diagnostics is None and allow_cached: - diagnostics = self._get_cached_published_diagnostics(uri) + 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 _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, @@ -789,7 +845,8 @@ class SolidLanguageServer(ABC): :return: A list of diagnostics for the file """ uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) - diagnostics_before_request = self._get_published_diagnostics_generation(uri) + 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 @@ -825,13 +882,11 @@ class SolidLanguageServer(ABC): ret.append(ls_types.Diagnostic(**new_item)) if not ret: - published_diagnostics = self._wait_for_published_diagnostics( - uri=uri, + published_diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, after_generation=diagnostics_before_request, - timeout=2.5 if pull_diagnostics_failed else 0.5, + timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed), ) - if published_diagnostics is None: - published_diagnostics = self._get_cached_published_diagnostics(uri) if published_diagnostics is not None: ret = published_diagnostics diff --git a/test/resources/repos/bash/test_repo/diagnostics_sample.sh b/test/resources/repos/bash/test_repo/diagnostics_sample.sh new file mode 100644 index 00000000..050a8a99 --- /dev/null +++ b/test/resources/repos/bash/test_repo/diagnostics_sample.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +if then + echo "$missingGreeting" +fi diff --git a/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr new file mode 100644 index 00000000..b408a202 --- /dev/null +++ b/test/resources/repos/crystal/test_repo/src/diagnostics_sample.cr @@ -0,0 +1,5 @@ +def broken_factory : String + missingGreeting +end + +broken_consumer = missingConsumerValue diff --git a/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart new file mode 100644 index 00000000..d309d9fc --- /dev/null +++ b/test/resources/repos/dart/test_repo/lib/diagnostics_sample.dart @@ -0,0 +1,3 @@ +String brokenFactory() { + return 'broken' +} diff --git a/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex new file mode 100644 index 00000000..af510437 --- /dev/null +++ b/test/resources/repos/elixir/test_repo/lib/diagnostics_sample.ex @@ -0,0 +1,10 @@ +defmodule DiagnosticsSample do + def broken_factory do + missing_greeting + end + + def broken_consumer do + value = broken_factory() + {value, missing_consumer_value} + end +end diff --git a/test/resources/repos/elm/test_repo/DiagnosticsSample.elm b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm new file mode 100644 index 00000000..2880fa7b --- /dev/null +++ b/test/resources/repos/elm/test_repo/DiagnosticsSample.elm @@ -0,0 +1,5 @@ +module DiagnosticsSample exposing (brokenFactory) + +brokenFactory : String +brokenFactory = + "unterminated diff --git a/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl new file mode 100644 index 00000000..edb18776 --- /dev/null +++ b/test/resources/repos/erlang/test_repo/src/diagnostics_sample.erl @@ -0,0 +1,5 @@ +-module(diagnostics_sample). +-export([broken_factory/0, broken_consumer/0]). + +broken_factory() -> MissingGreeting. +broken_consumer() -> MissingConsumerValue. diff --git a/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 new file mode 100644 index 00000000..2c23e5df --- /dev/null +++ b/test/resources/repos/fortran/test_repo/diagnostics_sample.f90 @@ -0,0 +1,5 @@ +program diagnostics_sample + implicit none + integer :: duplicated_value + real :: duplicated_value +end program diagnostics_sample diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy new file mode 100644 index 00000000..a176203e --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/DiagnosticsSample.groovy @@ -0,0 +1,13 @@ +package com.example + +class DiagnosticsSample { + static String brokenFactory() { + missingGreeting + } + + static void brokenConsumer() { + def value = brokenFactory() + println(value) + println(missingConsumerValue) + } +} diff --git a/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs new file mode 100644 index 00000000..e5777b1f --- /dev/null +++ b/test/resources/repos/haskell/test_repo/src/DiagnosticsSample.hs @@ -0,0 +1,7 @@ +module DiagnosticsSample where + +brokenFactory :: String +brokenFactory = missingGreeting + +brokenConsumer :: String +brokenConsumer = missingConsumerValue diff --git a/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx new file mode 100644 index 00000000..3b2e2c29 --- /dev/null +++ b/test/resources/repos/haxe/test_repo/src/DiagnosticsSample.hx @@ -0,0 +1,10 @@ +class DiagnosticsSample { + static function brokenFactory():String { + return missingGreeting; + } + + static function brokenConsumer():Void { + trace(brokenFactory()); + trace(missingConsumerValue); + } +} diff --git a/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl new file mode 100644 index 00000000..ffc8075c --- /dev/null +++ b/test/resources/repos/hlsl/test_repo/diagnostics_sample.hlsl @@ -0,0 +1,3 @@ +float4 main() : SV_Target { + return missingGreeting; +} diff --git a/test/resources/repos/json/test_repo/diagnostics_sample.json b/test/resources/repos/json/test_repo/diagnostics_sample.json new file mode 100644 index 00000000..0cc75efd --- /dev/null +++ b/test/resources/repos/json/test_repo/diagnostics_sample.json @@ -0,0 +1,4 @@ +{ + "name": "diagnostics", + "value": +} diff --git a/test/resources/repos/julia/test_repo/diagnostics_sample.jl b/test/resources/repos/julia/test_repo/diagnostics_sample.jl new file mode 100644 index 00000000..ef7e4a51 --- /dev/null +++ b/test/resources/repos/julia/test_repo/diagnostics_sample.jl @@ -0,0 +1,3 @@ +function broken_factory( + value = 1 +end diff --git a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean index a2c570ec..169754f7 100644 --- a/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean +++ b/test/resources/repos/lean4/test_repo/DiagnosticsSample.lean @@ -1,6 +1 @@ def brokenFactory : Nat := - missingGreeting - -def brokenConsumer : Nat := - let value := brokenFactory - value + missingConsumerValue diff --git a/test/resources/repos/matlab/test_repo/diagnostics_sample.m b/test/resources/repos/matlab/test_repo/diagnostics_sample.m new file mode 100644 index 00000000..b0f87a62 --- /dev/null +++ b/test/resources/repos/matlab/test_repo/diagnostics_sample.m @@ -0,0 +1,3 @@ +function value = diagnostics_sample() +value = missingGreeting; +end diff --git a/test/resources/repos/nix/test_repo/diagnostics_sample.nix b/test/resources/repos/nix/test_repo/diagnostics_sample.nix new file mode 100644 index 00000000..320a6ab8 --- /dev/null +++ b/test/resources/repos/nix/test_repo/diagnostics_sample.nix @@ -0,0 +1,4 @@ +{ + greeting = missingGreeting; + consumer = missingConsumerValue; +} diff --git a/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml new file mode 100644 index 00000000..c23ccefe --- /dev/null +++ b/test/resources/repos/ocaml/test_repo/lib/diagnostics_sample.ml @@ -0,0 +1 @@ +let broken_factory ( diff --git a/test/resources/repos/ocaml/test_repo/lib/dune b/test/resources/repos/ocaml/test_repo/lib/dune index fe41978f..aba011b1 100644 --- a/test/resources/repos/ocaml/test_repo/lib/dune +++ b/test/resources/repos/ocaml/test_repo/lib/dune @@ -1,3 +1,4 @@ (library (public_name test_repo) - (name test_repo)) \ No newline at end of file + (name test_repo) + (modules test_repo)) diff --git a/test/resources/repos/pascal/test_repo/diagnostics_sample.pas b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas new file mode 100644 index 00000000..7a3bf84b --- /dev/null +++ b/test/resources/repos/pascal/test_repo/diagnostics_sample.pas @@ -0,0 +1,4 @@ +program DiagnosticsSample; +begin + writeln( +end. diff --git a/test/resources/repos/perl/test_repo/diagnostics_sample.pl b/test/resources/repos/perl/test_repo/diagnostics_sample.pl new file mode 100644 index 00000000..4d78b686 --- /dev/null +++ b/test/resources/repos/perl/test_repo/diagnostics_sample.pl @@ -0,0 +1,5 @@ +use strict; +use warnings; + +print $missingGreeting; +print $missingConsumerValue; diff --git a/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 b/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 index d06165f2..c5ce7600 100644 --- a/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 +++ b/test/resources/repos/powershell/test_repo/diagnostics_sample.ps1 @@ -1,9 +1,4 @@ function Invoke-BrokenFactory { - Invoke-MissingGreeting -} - -function Invoke-BrokenConsumer { - $value = Invoke-BrokenFactory - Write-Output $value - Invoke-MissingConsumerValue + if ($true) { + Write-Output "broken" } diff --git a/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala new file mode 100644 index 00000000..ca9d929b --- /dev/null +++ b/test/resources/repos/scala/test_repo/src/main/scala/com/example/DiagnosticsSample.scala @@ -0,0 +1,9 @@ +package com.example + +object DiagnosticsSample { + def brokenFactory(): String = missingGreeting + def brokenConsumer(): Unit = { + println(brokenFactory()) + println(missingConsumerValue) + } +} diff --git a/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol new file mode 100644 index 00000000..7b2a0892 --- /dev/null +++ b/test/resources/repos/solidity/test_repo/contracts/DiagnosticsSample.sol @@ -0,0 +1,7 @@ +pragma solidity ^0.8.0; + +contract DiagnosticsSample { + function brokenFactory() pure returns (string memory) { + return "broken"; + } +} diff --git a/test/resources/repos/swift/test_repo/Package.swift b/test/resources/repos/swift/test_repo/Package.swift index 13a02704..5e9b7cff 100644 --- a/test/resources/repos/swift/test_repo/Package.swift +++ b/test/resources/repos/swift/test_repo/Package.swift @@ -11,6 +11,7 @@ let package = Package( targets: [ .target( name: "test_repo", - dependencies: []), + dependencies: [], + path: "src"), ] -) \ No newline at end of file +) diff --git a/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift new file mode 100644 index 00000000..b70b2c68 --- /dev/null +++ b/test/resources/repos/swift/test_repo/src/diagnostics_sample.swift @@ -0,0 +1,3 @@ +func brokenFactory() -> String { + return +} diff --git a/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv new file mode 100644 index 00000000..b5364262 --- /dev/null +++ b/test/resources/repos/systemverilog/test_repo/diagnostics_sample.sv @@ -0,0 +1,5 @@ +module diagnostics_sample; + initial begin + if ( + end +endmodule diff --git a/test/resources/repos/terraform/test_repo/diagnostics_sample.tf b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf new file mode 100644 index 00000000..15de22c0 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/diagnostics_sample.tf @@ -0,0 +1,4 @@ +resource "aws_instance" "broken" { + ami = + instance_type = "t2.micro" +} diff --git a/test/resources/repos/toml/test_repo/diagnostics_sample.toml b/test/resources/repos/toml/test_repo/diagnostics_sample.toml new file mode 100644 index 00000000..ed4ae4c1 --- /dev/null +++ b/test/resources/repos/toml/test_repo/diagnostics_sample.toml @@ -0,0 +1,2 @@ +[diagnostics] +value = diff --git a/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue b/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue new file mode 100644 index 00000000..17bd62e3 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/DiagnosticsSample.vue @@ -0,0 +1,3 @@ + diff --git a/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml new file mode 100644 index 00000000..c07e469d --- /dev/null +++ b/test/resources/repos/yaml/test_repo/diagnostics_sample.yaml @@ -0,0 +1,5 @@ +name: diagnostics +value: + - good + - + broken: [ diff --git a/test/resources/repos/zig/test_repo/build.zig b/test/resources/repos/zig/test_repo/build.zig index da7317d0..c9c1ff5a 100644 --- a/test/resources/repos/zig/test_repo/build.zig +++ b/test/resources/repos/zig/test_repo/build.zig @@ -28,4 +28,4 @@ pub fn build(b: *std.Build) void { const run_lib_tests = b.addRunArtifact(lib_tests); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_lib_tests.step); -} \ No newline at end of file +} diff --git a/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig new file mode 100644 index 00000000..844d6f14 --- /dev/null +++ b/test/resources/repos/zig/test_repo/src/diagnostics_sample.zig @@ -0,0 +1,3 @@ +pub fn brokenFactory() void { + const value = +} diff --git a/test/resources/repos/zig/test_repo/src/main.zig b/test/resources/repos/zig/test_repo/src/main.zig index 326b2cd0..156adabd 100644 --- a/test/resources/repos/zig/test_repo/src/main.zig +++ b/test/resources/repos/zig/test_repo/src/main.zig @@ -29,4 +29,4 @@ pub fn main() !void { pub fn greeting(name: []const u8) []const u8 { return std.fmt.allocPrint(std.heap.page_allocator, "Hello, {s}!", .{name}) catch "Hello!"; -} \ No newline at end of file +} diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index 8fd3a73b..ec23cf33 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -68,13 +68,13 @@ # name: test_delete_symbol[test_case1] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - - + + export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -521,7 +521,7 @@ # name: test_insert_in_rel_to_symbol[test_case1-after] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + export class DemoClass { value: number; constructor(value: number) { @@ -539,7 +539,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -555,7 +555,7 @@ # name: test_insert_in_rel_to_symbol[test_case1-before] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + function newFunctionAfterClass(): void { console.log("This function is after DemoClass."); } @@ -573,7 +573,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -589,7 +589,7 @@ # name: test_insert_in_rel_to_symbol[test_case2-after] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + export class DemoClass { value: number; constructor(value: number) { @@ -603,7 +603,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -623,7 +623,7 @@ # name: test_insert_in_rel_to_symbol[test_case2-before] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + export class DemoClass { value: number; constructor(value: number) { @@ -641,7 +641,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } @@ -1712,7 +1712,7 @@ # name: test_replace_body[test_case1] ''' import { ConsoleGreeter, Greeter } from "./formatters"; - + export class DemoClass { value: number; constructor(value: number) { @@ -1727,7 +1727,7 @@ export function helperFunction() { const demo = new DemoClass(42); demo.printValue(); - + const greeter: Greeter = new ConsoleGreeter(); console.log(greeter.formatGreeting("World")); } diff --git a/test/solidlsp/bash/test_bash_diagnostics.py b/test/solidlsp/bash/test_bash_diagnostics.py new file mode 100644 index 00000000..bd1cc494 --- /dev/null +++ b/test/solidlsp/bash/test_bash_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.bash +class TestBashDiagnostics: + @pytest.mark.parametrize("language_server", [Language.BASH], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.sh", + (), + min_count=1, + ) diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py index 5e8e1c93..f7d9dad6 100644 --- a/test/solidlsp/clojure/__init__.py +++ b/test/solidlsp/clojure/__init__.py @@ -13,8 +13,8 @@ def _test_clojure_cli() -> bool: CLI_FAIL = _test_clojure_cli() TEST_APP_PATH = Path("src") / "test_app" -CORE_PATH = str(TEST_APP_PATH / "core.clj").replace("\\", "/") -UTILS_PATH = str(TEST_APP_PATH / "utils.clj").replace("\\", "/") +CORE_PATH = str(TEST_APP_PATH / "core.clj") +UTILS_PATH = str(TEST_APP_PATH / "utils.clj") def is_clojure_cli_available() -> bool: diff --git a/test/solidlsp/clojure/test_clojure_diagnostics.py b/test/solidlsp/clojure/test_clojure_diagnostics.py new file mode 100644 index 00000000..a7bff96b --- /dev/null +++ b/test/solidlsp/clojure/test_clojure_diagnostics.py @@ -0,0 +1,19 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.clojure +class TestClojureDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("src", "test_app", "diagnostics_sample.clj"), + ("missing-greeting", "missing-consumer-value"), + min_count=2, + ) diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py index 08bb6a01..ee563757 100644 --- a/test/solidlsp/conftest.py +++ b/test/solidlsp/conftest.py @@ -5,6 +5,12 @@ from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation PYTHON_BACKEND_LANGUAGES = [Language.PYTHON, Language.PYTHON_TY] +def is_diagnostics_test_file(relative_path: str) -> bool: + normalized_path = relative_path.replace("\\", "/") + filename = normalized_path.rsplit("/", 1)[-1].lower() + return filename.startswith(("diagnosticssample.", "diagnostics_sample.")) + + def has_malformed_name( symbol: UnifiedSymbolInformation, whitespace_allowed: bool = False, @@ -36,6 +42,9 @@ def request_all_symbols(language_server: SolidLanguageServer) -> list[UnifiedSym result: list[UnifiedSymbolInformation] = [] def visit(symbol: UnifiedSymbolInformation) -> None: + relative_path = symbol.get("location", {}).get("relativePath", "") + if relative_path and is_diagnostics_test_file(relative_path): + return result.append(symbol) for child in symbol.get("children", []): visit(child) diff --git a/test/solidlsp/cpp/test_cpp_diagnostics.py b/test/solidlsp/cpp/test_cpp_diagnostics.py new file mode 100644 index 00000000..20a58368 --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.cpp +class TestCppDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CPP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.cpp", + (), + min_count=1, + ) diff --git a/test/solidlsp/crystal/test_crystal_diagnostics.py b/test/solidlsp/crystal/test_crystal_diagnostics.py new file mode 100644 index 00000000..e650adc1 --- /dev/null +++ b/test/solidlsp/crystal/test_crystal_diagnostics.py @@ -0,0 +1,23 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +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"), +] + + +class TestCrystalDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CRYSTAL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.cr", + (), + min_count=1, + ) diff --git a/test/solidlsp/csharp/test_csharp_diagnostics.py b/test/solidlsp/csharp/test_csharp_diagnostics.py new file mode 100644 index 00000000..de32fd1d --- /dev/null +++ b/test/solidlsp/csharp/test_csharp_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.csharp +class TestCsharpDiagnostics: + @pytest.mark.parametrize("language_server", [Language.CSHARP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.cs", + (), + min_count=1, + ) diff --git a/test/solidlsp/dart/test_dart_diagnostics.py b/test/solidlsp/dart/test_dart_diagnostics.py new file mode 100644 index 00000000..ddc5e398 --- /dev/null +++ b/test/solidlsp/dart/test_dart_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.dart +class TestDartDiagnostics: + @pytest.mark.parametrize("language_server", [Language.DART], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.dart", + (), + min_count=1, + ) diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py index 11b983ca..7c44abc4 100644 --- a/test/solidlsp/elixir/test_elixir_basic.py +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -13,6 +13,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language 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 from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON @@ -138,3 +139,12 @@ class TestElixirBasic: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.ex", + (), + min_count=1, + ) diff --git a/test/solidlsp/elm/test_elm_diagnostics.py b/test/solidlsp/elm/test_elm_diagnostics.py new file mode 100644 index 00000000..744201a2 --- /dev/null +++ b/test/solidlsp/elm/test_elm_diagnostics.py @@ -0,0 +1,20 @@ +import shutil + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.elm +@pytest.mark.skipif(shutil.which("node") is None or shutil.which("npm") is None, reason="Elm diagnostics require Node.js and npm") +class TestElmDiagnostics: + @pytest.mark.parametrize("language_server", [Language.ELM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.elm", + (), + min_count=1, + ) diff --git a/test/solidlsp/erlang/test_erlang_basic.py b/test/solidlsp/erlang/test_erlang_basic.py index a1dba06a..dc39535b 100644 --- a/test/solidlsp/erlang/test_erlang_basic.py +++ b/test/solidlsp/erlang/test_erlang_basic.py @@ -10,6 +10,7 @@ import pytest from solidlsp import SolidLanguageServer 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 from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON @@ -55,3 +56,12 @@ class TestErlangLanguageServerBasics: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.erl", + (), + min_count=1, + ) diff --git a/test/solidlsp/fortran/test_fortran_diagnostics.py b/test/solidlsp/fortran/test_fortran_diagnostics.py new file mode 100644 index 00000000..f607cd0b --- /dev/null +++ b/test/solidlsp/fortran/test_fortran_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.fortran +class TestFortranDiagnostics: + @pytest.mark.parametrize("language_server", [Language.FORTRAN], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.f90", + (), + min_count=1, + ) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index f58ec0b1..f4f49ebc 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -9,6 +9,7 @@ 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.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, there seems to be a regression or instability. @@ -196,21 +197,13 @@ class TestFSharpLanguageServer: assert isinstance(result["value"], list), "Completions should be a list" @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) - def test_diagnostics(self, language_server: SolidLanguageServer) -> None: - """Test getting diagnostics (errors, warnings) from F# files.""" - file_path = os.path.join("Program.fs") - - # FsAutoComplete uses publishDiagnostics notifications instead of textDocument/diagnostic requests - # So we'll test that the language server can handle files without crashing - # In real usage, diagnostics would come through the publishDiagnostics notification handler - - # Test that we can at least work with the file (open/close cycle) - with language_server.open_file(file_path) as _: - # If we can open and close the file without errors, basic diagnostics support is working - pass - - # This is a successful test - FsAutoComplete is working with F# files - assert True, "F# language server can handle files successfully" + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.fs", + (), + min_count=1, + ) @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_bare_symbol_names(self, language_server) -> None: diff --git a/test/solidlsp/go/test_go_diagnostics.py b/test/solidlsp/go/test_go_diagnostics.py new file mode 100644 index 00000000..00b4cc48 --- /dev/null +++ b/test/solidlsp/go/test_go_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.go +class TestGoDiagnostics: + @pytest.mark.parametrize("language_server", [Language.GO], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.go", + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) diff --git a/test/solidlsp/groovy/test_groovy_basic.py b/test/solidlsp/groovy/test_groovy_basic.py index 47f22ed8..0637e3de 100644 --- a/test/solidlsp/groovy/test_groovy_basic.py +++ b/test/solidlsp/groovy/test_groovy_basic.py @@ -9,6 +9,7 @@ from solidlsp.ls_config import Language, LanguageServerConfig 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 +from test.solidlsp.util.diagnostics import assert_file_diagnostics @pytest.mark.groovy @@ -117,3 +118,12 @@ class TestGroovyLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + def test_file_diagnostics(self) -> None: + assert self.language_server is not None + assert_file_diagnostics( + self.language_server, + "src/main/groovy/com/example/DiagnosticsSample.groovy", + (), + min_count=1, + ) diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py index 67452151..5b23ad3d 100644 --- a/test/solidlsp/haskell/test_haskell_basic.py +++ b/test/solidlsp/haskell/test_haskell_basic.py @@ -21,6 +21,7 @@ from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language 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 @pytest.mark.haskell @@ -240,3 +241,12 @@ class TestHaskellLanguageServer: for sym in malformed_symbols ] pytest.fail(f"Found malformed symbols: {diagnostics}", pytrace=False) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.hs", + (), + min_count=1, + ) diff --git a/test/solidlsp/haxe/test_haxe_diagnostics.py b/test/solidlsp/haxe/test_haxe_diagnostics.py new file mode 100644 index 00000000..2f789b60 --- /dev/null +++ b/test/solidlsp/haxe/test_haxe_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.haxe +class TestHaxeDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HAXE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.hx", + (), + min_count=1, + ) diff --git a/test/solidlsp/hlsl/test_hlsl_diagnostics.py b/test/solidlsp/hlsl/test_hlsl_diagnostics.py new file mode 100644 index 00000000..e356d2da --- /dev/null +++ b/test/solidlsp/hlsl/test_hlsl_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.hlsl +class TestHlslDiagnostics: + @pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.hlsl", + (), + min_count=1, + ) diff --git a/test/solidlsp/java/test_java_diagnostics.py b/test/solidlsp/java/test_java_diagnostics.py new file mode 100644 index 00000000..ebd59457 --- /dev/null +++ b/test/solidlsp/java/test_java_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.java +class TestJavaDiagnostics: + @pytest.mark.parametrize("language_server", [Language.JAVA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/main/java/test_repo/DiagnosticsSample.java", + (), + min_count=1, + ) diff --git a/test/solidlsp/json_ls/test_json_diagnostics.py b/test/solidlsp/json_ls/test_json_diagnostics.py new file mode 100644 index 00000000..7850083a --- /dev/null +++ b/test/solidlsp/json_ls/test_json_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.json +class TestJsonDiagnostics: + @pytest.mark.parametrize("language_server", [Language.JSON], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.json", + (), + min_count=1, + ) diff --git a/test/solidlsp/julia/test_julia_basic.py b/test/solidlsp/julia/test_julia_basic.py index 44adb43d..176587f7 100644 --- a/test/solidlsp/julia/test_julia_basic.py +++ b/test/solidlsp/julia/test_julia_basic.py @@ -6,6 +6,7 @@ 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") @@ -66,3 +67,12 @@ class TestJuliaLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.JULIA], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.jl", + (), + min_count=1, + ) diff --git a/test/solidlsp/kotlin/test_kotlin_diagnostics.py b/test/solidlsp/kotlin/test_kotlin_diagnostics.py new file mode 100644 index 00000000..c189825f --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.kotlin +class TestKotlinDiagnostics: + @pytest.mark.parametrize("language_server", [Language.KOTLIN], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/main/kotlin/test_repo/DiagnosticsSample.kt", + (), + min_count=1, + ) diff --git a/test/solidlsp/lean4/test_lean4_diagnostics.py b/test/solidlsp/lean4/test_lean4_diagnostics.py new file mode 100644 index 00000000..964bc2c6 --- /dev/null +++ b/test/solidlsp/lean4/test_lean4_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.lean4 +class TestLean4Diagnostics: + @pytest.mark.parametrize("language_server", [Language.LEAN4], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "DiagnosticsSample.lean", + (), + min_count=1, + ) diff --git a/test/solidlsp/matlab/test_matlab_basic.py b/test/solidlsp/matlab/test_matlab_basic.py index 71724ff7..a1582887 100644 --- a/test/solidlsp/matlab/test_matlab_basic.py +++ b/test/solidlsp/matlab/test_matlab_basic.py @@ -17,6 +17,7 @@ import pytest from solidlsp import SolidLanguageServer 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 # Skip all tests if MATLAB is not available pytestmark = pytest.mark.matlab @@ -129,3 +130,12 @@ class TestMatlabLanguageServerReferences: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.MATLAB], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.m", + (), + min_count=1, + ) diff --git a/test/solidlsp/nix/test_nix_basic.py b/test/solidlsp/nix/test_nix_basic.py index 23f4311a..e66ddfa5 100644 --- a/test/solidlsp/nix/test_nix_basic.py +++ b/test/solidlsp/nix/test_nix_basic.py @@ -13,6 +13,7 @@ from solidlsp.ls_config import Language from solidlsp.ls_types import SymbolKind 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") @@ -250,3 +251,12 @@ class TestNixLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.NIX], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.nix", + (), + min_count=1, + ) diff --git a/test/solidlsp/ocaml/test_ocaml_diagnostics.py b/test/solidlsp/ocaml/test_ocaml_diagnostics.py new file mode 100644 index 00000000..a76f95cb --- /dev/null +++ b/test/solidlsp/ocaml/test_ocaml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.ocaml +class TestOcamlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "lib/diagnostics_sample.ml", + (), + min_count=1, + ) diff --git a/test/solidlsp/pascal/test_pascal_diagnostics.py b/test/solidlsp/pascal/test_pascal_diagnostics.py new file mode 100644 index 00000000..ad96a1ea --- /dev/null +++ b/test/solidlsp/pascal/test_pascal_diagnostics.py @@ -0,0 +1,25 @@ +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.pascal, + pytest.mark.skipif(not language_tests_enabled(Language.PASCAL), reason="Pascal tests are disabled"), + pytest.mark.skipif(shutil.which("fpc") is None, reason="Pascal diagnostics require the Free Pascal compiler"), +] + + +class TestPascalDiagnostics: + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.pas", + (), + min_count=1, + ) diff --git a/test/solidlsp/perl/test_perl_basic.py b/test/solidlsp/perl/test_perl_basic.py index 41682e3f..efb01d01 100644 --- a/test/solidlsp/perl/test_perl_basic.py +++ b/test/solidlsp/perl/test_perl_basic.py @@ -6,6 +6,7 @@ import pytest from solidlsp import SolidLanguageServer 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 @pytest.mark.perl @@ -88,3 +89,12 @@ class TestPerlLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.PERL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.pl", + (), + min_count=1, + ) diff --git a/test/solidlsp/php/test_php_diagnostics.py b/test/solidlsp/php/test_php_diagnostics.py new file mode 100644 index 00000000..1cfc67de --- /dev/null +++ b/test/solidlsp/php/test_php_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.php +class TestPhpDiagnostics: + @pytest.mark.parametrize("language_server", [Language.PHP], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.php", + (), + min_count=1, + ) diff --git a/test/solidlsp/powershell/test_powershell_diagnostics.py b/test/solidlsp/powershell/test_powershell_diagnostics.py new file mode 100644 index 00000000..ac8706cf --- /dev/null +++ b/test/solidlsp/powershell/test_powershell_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.powershell +class TestPowershellDiagnostics: + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.ps1", + (), + min_count=1, + ) diff --git a/test/solidlsp/python/test_python_diagnostics.py b/test/solidlsp/python/test_python_diagnostics.py new file mode 100644 index 00000000..386d2830 --- /dev/null +++ b/test/solidlsp/python/test_python_diagnostics.py @@ -0,0 +1,19 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from test.conftest import PYTHON_LANGUAGE_BACKENDS +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.python +class TestPythonDiagnostics: + @pytest.mark.parametrize("language_server", PYTHON_LANGUAGE_BACKENDS, indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + os.path.join("test_repo", "diagnostics_sample.py"), + ("missing_user", "undefined_name"), + min_count=2, + ) diff --git a/test/solidlsp/rust/test_rust_basic.py b/test/solidlsp/rust/test_rust_basic.py index 29fce938..4c7ec2c0 100644 --- a/test/solidlsp/rust/test_rust_basic.py +++ b/test/solidlsp/rust/test_rust_basic.py @@ -90,7 +90,7 @@ class TestRustLanguageServer: all_symbols = request_all_symbols(language_server) malformed_symbols = [] for s in all_symbols: - if has_malformed_name(s): + if has_malformed_name(s, whitespace_allowed=True): malformed_symbols.append(s) if malformed_symbols: pytest.fail( diff --git a/test/solidlsp/rust/test_rust_diagnostics.py b/test/solidlsp/rust/test_rust_diagnostics.py new file mode 100644 index 00000000..b08f7cf7 --- /dev/null +++ b/test/solidlsp/rust/test_rust_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.rust +class TestRustDiagnostics: + @pytest.mark.parametrize("language_server", [Language.RUST], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.rs", + (), + min_count=1, + ) diff --git a/test/solidlsp/scala/test_scala_language_server.py b/test/solidlsp/scala/test_scala_language_server.py index bfc6d8f1..4e60209d 100644 --- a/test/solidlsp/scala/test_scala_language_server.py +++ b/test/solidlsp/scala/test_scala_language_server.py @@ -6,6 +6,7 @@ import pytest from solidlsp.language_servers.scala_language_server import ScalaLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings +from test.solidlsp.util.diagnostics import assert_file_diagnostics pytest.skip("Scala must be compiled for these tests to run through, which is a huge hassle", allow_module_level=True) @@ -62,3 +63,12 @@ def test_scala_find_definition_and_references_across_files(scala_ls): assert first_def["range"]["start"]["character"] == 6 assert first_def["range"]["end"]["line"] == 7 assert first_def["range"]["end"]["character"] == 14 + + +def test_file_diagnostics(scala_ls) -> None: + assert_file_diagnostics( + scala_ls, + "src/main/scala/com/example/DiagnosticsSample.scala", + (), + min_count=1, + ) diff --git a/test/solidlsp/solidity/test_solidity_diagnostics.py b/test/solidlsp/solidity/test_solidity_diagnostics.py new file mode 100644 index 00000000..fd3cd6ba --- /dev/null +++ b/test/solidlsp/solidity/test_solidity_diagnostics.py @@ -0,0 +1,24 @@ +import sys + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@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( + language_server, + "contracts/DiagnosticsSample.sol", + (), + min_count=1, + ) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index 5a0d8bd0..30e9759c 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -16,6 +16,7 @@ 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 +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" @@ -238,3 +239,12 @@ class TestSwiftProjectBasics: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.SWIFT], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.swift", + (), + min_count=1, + ) diff --git a/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py new file mode 100644 index 00000000..a783b828 --- /dev/null +++ b/test/solidlsp/systemverilog/test_systemverilog_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.systemverilog +class TestSystemverilogDiagnostics: + @pytest.mark.parametrize("language_server", [Language.SYSTEMVERILOG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.sv", + (), + min_count=1, + ) diff --git a/test/solidlsp/terraform/test_terraform_diagnostics.py b/test/solidlsp/terraform/test_terraform_diagnostics.py new file mode 100644 index 00000000..318cc7f9 --- /dev/null +++ b/test/solidlsp/terraform/test_terraform_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.terraform +class TestTerraformDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.tf", + (), + min_count=1, + ) diff --git a/test/solidlsp/toml/test_toml_diagnostics.py b/test/solidlsp/toml/test_toml_diagnostics.py new file mode 100644 index 00000000..dde2fc83 --- /dev/null +++ b/test/solidlsp/toml/test_toml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.toml +class TestTomlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.toml", + (), + min_count=1, + ) diff --git a/test/solidlsp/typescript/test_typescript_diagnostics.py b/test/solidlsp/typescript/test_typescript_diagnostics.py new file mode 100644 index 00000000..5e879e16 --- /dev/null +++ b/test/solidlsp/typescript/test_typescript_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.typescript +class TestTypeScriptDiagnostics: + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.ts", + ("missingGreeting", "missingConsumerValue"), + min_count=2, + ) diff --git a/test/solidlsp/util/diagnostics.py b/test/solidlsp/util/diagnostics.py new file mode 100644 index 00000000..dee66086 --- /dev/null +++ b/test/solidlsp/util/diagnostics.py @@ -0,0 +1,19 @@ +from collections.abc import Sequence + +from solidlsp import SolidLanguageServer + + +def assert_file_diagnostics( + language_server: SolidLanguageServer, + relative_file_path: str, + expected_message_fragments: Sequence[str], + min_count: int = 1, +) -> None: + diagnostics = language_server.request_text_document_diagnostics(relative_file_path, min_severity=1) + + assert isinstance(diagnostics, list), diagnostics + assert len(diagnostics) >= min_count, diagnostics + + diagnostic_messages = [diagnostic["message"] for diagnostic in diagnostics] + for fragment in expected_message_fragments: + assert any(fragment in message for message in diagnostic_messages), diagnostic_messages diff --git a/test/solidlsp/vue/test_vue_diagnostics.py b/test/solidlsp/vue/test_vue_diagnostics.py new file mode 100644 index 00000000..08ac2fed --- /dev/null +++ b/test/solidlsp/vue/test_vue_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.vue +class TestVueDiagnostics: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/DiagnosticsSample.vue", + (), + min_count=1, + ) diff --git a/test/solidlsp/yaml_ls/test_yaml_diagnostics.py b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py new file mode 100644 index 00000000..4ba774c7 --- /dev/null +++ b/test/solidlsp/yaml_ls/test_yaml_diagnostics.py @@ -0,0 +1,17 @@ +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.solidlsp.util.diagnostics import assert_file_diagnostics + + +@pytest.mark.yaml +class TestYamlDiagnostics: + @pytest.mark.parametrize("language_server", [Language.YAML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "diagnostics_sample.yaml", + (), + min_count=1, + ) diff --git a/test/solidlsp/zig/test_zig_basic.py b/test/solidlsp/zig/test_zig_basic.py index 51b23650..e7413482 100644 --- a/test/solidlsp/zig/test_zig_basic.py +++ b/test/solidlsp/zig/test_zig_basic.py @@ -14,6 +14,7 @@ from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language 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 @pytest.mark.zig @@ -355,3 +356,12 @@ class TestZigLanguageServer: f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}", pytrace=False, ) + + @pytest.mark.parametrize("language_server", [Language.ZIG], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + assert_file_diagnostics( + language_server, + "src/diagnostics_sample.zig", + (), + min_count=1, + ) From da59a19c5060ba896c73c06a5886e054f9f8ddc7 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 29 Apr 2026 09:25:58 +0200 Subject: [PATCH 37/48] Fixes in various LS and in tests to accommodate diagnostics edge behavior Also minor fix in go test expectations of symbol name --- .github/workflows/pytest.yml | 4 +- scripts/demo_run_tools.py | 6 +- .../language_servers/bash_language_server.py | 133 +++++++++++++++--- .../language_servers/elm_language_server.py | 24 ++++ .../fortran_language_server.py | 33 ++++- .../language_servers/haxe_language_server.py | 9 +- src/solidlsp/language_servers/julia_server.py | 49 ++++++- .../language_servers/ocaml_lsp_server.py | 10 +- .../powershell_language_server.py | 62 +++++++- .../language_servers/pyright_server.py | 49 +++++++ .../solidity_language_server.py | 132 ++++++++++++++++- src/solidlsp/ls.py | 99 ++++++++++--- test/serena/test_serena_agent.py | 2 +- test/solidlsp/go/test_go_basic.py | 4 +- 14 files changed, 560 insertions(+), 56 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 86246a2e..1d2b79a6 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -235,10 +235,10 @@ jobs: - name: Set up Julia uses: julia-actions/setup-julia@v2 with: - version: '1.10' + version: '1.12.6' - name: Install Julia LanguageServer shell: bash - run: julia -e 'using Pkg; Pkg.add("LanguageServer")' + 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 diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index 4914201c..1c95365c 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -8,11 +8,12 @@ from pathlib import Path from pprint import pprint from serena.agent import SerenaAgent -from serena.config.serena_config import SerenaConfig +from serena.config.serena_config import LanguageBackend, SerenaConfig from serena.constants import REPO_ROOT from serena.tools import ( FindFileTool, FindReferencingSymbolsTool, + GetDiagnosticsForFileTool, JetBrainsFindSymbolTool, JetBrainsGetSymbolsOverviewTool, JetBrainsInlineSymbol, @@ -24,6 +25,7 @@ from serena.tools import ( if __name__ == "__main__": serena_config = SerenaConfig.from_config_file() serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP # project = Path(REPO_ROOT).parent / "serena-jetbrains-plugin-copy" project = Path(REPO_ROOT) agent = SerenaAgent(project=str(project), serena_config=serena_config) @@ -40,7 +42,7 @@ if __name__ == "__main__": jb_inspections_tool = agent.get_tool(JetBrainsRunInspectionsTool) result = agent.execute_task( - lambda: overview_tool.apply( + lambda: diagnostics_in_file_tool.apply( # name_path_pattern="SerenaAgent", relative_path="test/resources/repos/clojure/test_repo/src/test_app/diagnostics_sample.clj", # keep_definition=True, diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index c618884b..9dfc9356 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -18,12 +18,64 @@ from solidlsp.ls import ( SolidLanguageServer, ) 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__) +# ShellCheck binary release info; download directly from upstream GitHub releases rather than +# relying on the `shellcheck` npm wrapper (which lazily downloads on first invocation and +# turned the bash LS install into a fragile, network-dependent step). +_SHELLCHECK_VERSION = "0.10.0" +_SHELLCHECK_RELEASE_BASE = f"https://github.com/koalaman/shellcheck/releases/download/v{_SHELLCHECK_VERSION}" +_SHELLCHECK_ALLOWED_HOSTS = ( + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +) +# Per-platform archive metadata: tar.xz on POSIX (extracts to shellcheck-v/shellcheck), +# zip on Windows (extracts to shellcheck.exe at archive root). +_SHELLCHECK_DEPENDENCIES: dict[PlatformId, dict[str, str]] = { + PlatformId.LINUX_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.x86_64.tar.xz", + "sha256": "6c881ab0698e4e6ea235245f22832860544f17ba386442fe7e9d629f8cbedf87", + }, + PlatformId.LINUX_arm64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.linux.aarch64.tar.xz", + "sha256": "324a7e89de8fa2aed0d0c28f3dab59cf84c6d74264022c00c22af665ed1a09bb", + }, + PlatformId.OSX_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.x86_64.tar.xz", + "sha256": "ef27684f23279d112d8ad84e0823642e43f838993bbb8c0963db9b58a90464c2", + }, + PlatformId.OSX_arm64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.darwin.aarch64.tar.xz", + "sha256": "bbd2f14826328eee7679da7221f2bc3afb011f6a928b848c80c321f6046ddf81", + }, + PlatformId.WIN_x64: { + "url": f"{_SHELLCHECK_RELEASE_BASE}/shellcheck-v{_SHELLCHECK_VERSION}.zip", + "sha256": "eb6cd53a54ea97a56540e9d296ce7e2fa68715aa507ff23574646c1e12b2e143", + }, +} + + +def _shellcheck_install_dir(bash_ls_dir: str) -> str: + return os.path.join(bash_ls_dir, "shellcheck") + + +def _shellcheck_binary_path(bash_ls_dir: str) -> str: + """ + Returns the path to the extracted ShellCheck binary. POSIX archives extract under + ``shellcheck-v/shellcheck``; the Windows zip drops ``shellcheck.exe`` at archive root. + """ + install_dir = _shellcheck_install_dir(bash_ls_dir) + if os.name == "nt": + return os.path.join(install_dir, "shellcheck.exe") + return os.path.join(install_dir, f"shellcheck-v{_SHELLCHECK_VERSION}", "shellcheck") + + class BashLanguageServer(SolidLanguageServer): """ Provides Bash specific instantiation of the LanguageServer class using bash-language-server. @@ -53,7 +105,7 @@ class BashLanguageServer(SolidLanguageServer): """ Setup runtime dependencies for Bash Language Server and return the command to start the server. """ - # Verify both node and npm are installed + # verify node + npm are available for the bash-language-server install is_node_installed = shutil.which("node") is not None assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None @@ -61,29 +113,29 @@ class BashLanguageServer(SolidLanguageServer): bash_language_server_version = self._custom_settings.get("bash_language_server_version", "5.6.0") npm_registry = self._custom_settings.get("npm_registry") - deps = RuntimeDependencyCollection( - [ - RuntimeDependency( - id="bash-language-server", - description="bash-language-server package", - command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry), - platform_id="any", - ), - ] - ) - - # Install bash-language-server if not already installed bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") - bash_executable_path = os.path.join(bash_ls_dir, "node_modules", ".bin", "bash-language-server") - - # Handle Windows executable extension + managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") + bash_executable_path = os.path.join(managed_bin_dir, "bash-language-server") if os.name == "nt": bash_executable_path += ".cmd" + # install bash-language-server via npm if not os.path.exists(bash_executable_path): - log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...") - deps.install(bash_ls_dir) - log.info("Bash language server dependencies installed successfully") + bls_deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="bash-language-server", + description="bash-language-server package", + command=build_npm_install_command("bash-language-server", bash_language_server_version, npm_registry), + platform_id="any", + ), + ] + ) + log.info("Installing bash-language-server...") + bls_deps.install(bash_ls_dir) + + # install ShellCheck binary directly from upstream releases for the current platform + self._install_shellcheck_if_missing(bash_ls_dir) if not os.path.exists(bash_executable_path): raise FileNotFoundError( @@ -92,6 +144,49 @@ class BashLanguageServer(SolidLanguageServer): return bash_executable_path + @staticmethod + def _install_shellcheck_if_missing(bash_ls_dir: str) -> None: + """ + Downloads and extracts the platform-appropriate ShellCheck release into + ``${bash_ls_dir}/shellcheck`` if the binary is not already present. + """ + binary_path = _shellcheck_binary_path(bash_ls_dir) + if os.path.exists(binary_path): + return + + install_dir = _shellcheck_install_dir(bash_ls_dir) + os.makedirs(install_dir, exist_ok=True) + + release = _SHELLCHECK_DEPENDENCIES.get(PlatformUtils.get_platform_id()) + if release is None: + raise RuntimeError(f"ShellCheck has no upstream binary release for platform {PlatformUtils.get_platform_id().value}") + + archive_type = "zip" if os.name == "nt" else "xztar" + log.info(f"Downloading ShellCheck v{_SHELLCHECK_VERSION} for {PlatformUtils.get_platform_id().value}") + FileUtils.download_and_extract_archive_verified( + release["url"], + install_dir, + archive_type, + expected_sha256=release["sha256"], + allowed_hosts=_SHELLCHECK_ALLOWED_HOSTS, + ) + + if not os.path.exists(binary_path): + raise FileNotFoundError(f"ShellCheck binary not found at {binary_path} after extraction; archive layout may have changed.") + + # ensure the binary is executable on POSIX (zip extraction does not preserve perms) + if os.name != "nt": + current = os.stat(binary_path).st_mode + os.chmod(binary_path, current | 0o111) + + def create_launch_command_env(self) -> dict[str, str]: + bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") + managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") + return { + "PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", ""), + "SHELLCHECK_PATH": _shellcheck_binary_path(bash_ls_dir), + } + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "start"] diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index 0c6139c1..a86a1d2d 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -11,6 +11,7 @@ import threading from overrides import override 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 @@ -217,3 +218,26 @@ class ElmLanguageServer(SolidLanguageServer): @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 1.0 + + @override + 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]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + + with self.open_file(relative_file_path): + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=uri, + after_generation=-1, + timeout=10.0, + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index a1d5d56d..a80efc68 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -11,7 +11,7 @@ import shutil from overrides import override from solidlsp import ls_types -from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer +from solidlsp.ls import DocumentSymbols, LSPConstants, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -285,3 +285,34 @@ class FortranLanguageServer(SolidLanguageServer): # Fortran Language Server is ready after initialization log.info("Fortran Language Server initialization complete") + + @override + 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]: + uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + + with self.open_file(relative_file_path): + self.server.notify.did_save_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + 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, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py index 41c63a2e..cf48c78a 100644 --- a/src/solidlsp/language_servers/haxe_language_server.py +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -215,7 +215,14 @@ class HaxeLanguageServer(SolidLanguageServer): "locale": "en", "capabilities": { "textDocument": { - "synchronization": {"didSave": True}, + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "didSave": True}, "completion": {"completionItem": {"snippetSupport": True}}, "definition": {}, "references": {}, diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index 41d23c02..ff3acd66 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -10,7 +10,7 @@ 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.lsp_types import DiagnosticTag, InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -115,6 +115,21 @@ class JuliaLanguageServer(SolidLanguageServer): """Define language-specific directories to ignore for Julia projects.""" return super().is_ignored_dirname(dirname) or dirname in [".julia", "build", "dist"] + @override + def _supports_pull_diagnostics(self) -> bool: + # LanguageServer.jl raises an unhandled error on textDocument/diagnostic which crashes + # the server process. Force the published-diagnostics path instead. + return False + + @override + def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: + # LanguageServer.jl needs significant warm-up after init before it emits the first + # publishDiagnostics: workspace/configuration round-trip + initial linter pass typically + # land 13-15s after the file is opened in a cold LS. Module-scoped fixtures usually have + # the cache pre-populated and return immediately, so this ceiling only kicks in for cold + # runs (standalone test invocations, CI cold start). + return 30.0 + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Julia Language Server. @@ -125,8 +140,19 @@ class JuliaLanguageServer(SolidLanguageServer): "rootPath": repository_absolute_path, "rootUri": root_uri, "capabilities": { - "workspace": {"workspaceFolders": True}, + # workspace.configuration MUST be true: LanguageServer.jl pulls all julia.lint.* + # settings via workspace/configuration after initialize. Without it, the server + # skips that codepath and runlinter stays disabled, so no diagnostics ever arrive. + "workspace": {"workspaceFolders": True, "configuration": True}, "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "didSave": True}, "definition": {"dynamicRegistration": True}, "references": {"dynamicRegistration": True}, "documentSymbol": {"dynamicRegistration": True}, @@ -150,6 +176,19 @@ class JuliaLanguageServer(SolidLanguageServer): def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") + def workspace_configuration(params: dict) -> list[Any]: + """ + Respond to LanguageServer.jl's workspace/configuration pull request. + + The server requests a flat list of julia.* settings (in particular julia.lint.run, + position 11 in the response). Returning ``None`` for each entry causes the server to + apply its built-in defaults — including ``runlinter = true`` — which is what we want + for diagnostics to be produced. + """ + items = params.get("items", []) if isinstance(params, dict) else [] + return [None for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) @@ -166,4 +205,10 @@ class JuliaLanguageServer(SolidLanguageServer): assert "documentSymbolProvider" in init_response["capabilities"] self.server.notify.initialized({}) + + # nudge the server to pull config: LanguageServer.jl only invokes request_julia_config + # from its workspace/didChangeConfiguration handler, so without this notification the + # server never asks us for julia.lint.run and runlinter stays disabled (no diagnostics). + self.server.notify.workspace_did_change_configuration({"settings": {}}) + log.info("Julia Language Server is initialized and ready.") diff --git a/src/solidlsp/language_servers/ocaml_lsp_server.py b/src/solidlsp/language_servers/ocaml_lsp_server.py index 8c9bc356..b2e9511a 100644 --- a/src/solidlsp/language_servers/ocaml_lsp_server.py +++ b/src/solidlsp/language_servers/ocaml_lsp_server.py @@ -242,7 +242,7 @@ class OcamlLanguageServer(SolidLanguageServer): """ ocaml_ok = self._ocaml_version >= (5, 2, 0) lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS - return ocaml_ok and lsp_ok + return ocaml_ok and lsp_ok and self._index_built @staticmethod def _build_ocaml_index_static(repository_root_path: str) -> bool: @@ -340,6 +340,14 @@ class OcamlLanguageServer(SolidLanguageServer): "configuration": True, }, "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": False}, "synchronization": { "dynamicRegistration": True, "willSave": True, diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 72f9ac27..e737b09a 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -5,6 +5,8 @@ Contains various configurations and settings specific to PowerShell scripting. You can pass the following entries in ``ls_specific_settings["powershell"]``: - pses_version: Override the pinned PowerShell Editor Services version downloaded by Serena (default: the bundled Serena version). + - psscriptanalyzer_version: Override the pinned PSScriptAnalyzer version + saved into the bundled PowerShell Editor Services module path. """ import logging @@ -12,6 +14,7 @@ import os import pathlib import platform import shutil +import subprocess import tempfile import threading from collections.abc import Hashable @@ -19,7 +22,8 @@ from pathlib import Path from overrides import override -from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer +from solidlsp import ls_types +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 @@ -33,6 +37,7 @@ log = logging.getLogger(__name__) PSES_VERSION = "4.4.0" PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") +PSSCRIPTANALYZER_VERSION = "1.25.0" class PowerShellLanguageServer(SolidLanguageServer): @@ -157,6 +162,29 @@ class PowerShellLanguageServer(SolidLanguageServer): # The bundled modules path is the directory containing PowerShellEditorServices bundled_modules_path = str(Path(pses_path).parent) + psscriptanalyzer_version = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL).get( + "psscriptanalyzer_version", PSSCRIPTANALYZER_VERSION + ) + psscriptanalyzer_path = Path(bundled_modules_path) / "PSScriptAnalyzer" / psscriptanalyzer_version + if not psscriptanalyzer_path.exists(): + log.info(f"PSScriptAnalyzer {psscriptanalyzer_version} not found. Installing...") + subprocess.run( + [ + pwsh_path, + "-NoLogo", + "-NoProfile", + "-Command", + ( + "Save-Module " + "-Name PSScriptAnalyzer " + f"-RequiredVersion '{psscriptanalyzer_version}' " + f"-Path '{bundled_modules_path}' " + "-Force " + "-ErrorAction Stop" + ), + ], + check=True, + ) return pwsh_path, pses_path, bundled_modules_path @@ -345,3 +373,35 @@ class PowerShellLanguageServer(SolidLanguageServer): self.server_ready.set() else: log.info("PowerShell Editor Services initialization complete") + + @override + 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]: + 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) + + with self.open_file(relative_file_path): + self.server.notify.did_save_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + diagnostics = self._wait_for_relevant_published_diagnostics( + uri=published_uri, + after_generation=diagnostics_before_request, + timeout=self._get_published_diagnostics_wait_timeout(True), + allow_cached=True, + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index efeefdaa..7f774836 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -155,6 +155,52 @@ class PyrightServer(SolidLanguageServer): self.found_source_files = True self.analysis_complete.set() + def handle_pyright_progress_notification(progress_kind: str, params: object | None) -> None: + """Tracks Pyright-specific progress notifications. + + Pyright can emit custom progress notifications instead of only using + ``$/progress``. Handling them avoids noisy unhandled-method warnings + and provides an additional signal that initial analysis has quiesced. + """ + # normalizing the notification payload + message_text = "" + percentage: object | None = None + if isinstance(params, dict): + raw_message = params.get("message") + message_text = "" if raw_message is None else str(raw_message) + percentage = params.get("percentage") + elif params is not None: + message_text = str(params) + + progress_label = f"{message_text} ({percentage}%)" if percentage is not None else message_text + + # logging the progress transition + if progress_kind == "begin": + log.info("Pyright progress started: %s", progress_label) + return + + if progress_kind == "report": + log.debug("Pyright progress update: %s", progress_label) + return + + log.info("Pyright progress finished: %s", progress_label) + self.analysis_complete.set() + + def pyright_begin_progress(params: object | None) -> None: + """Handles the ``pyright/beginProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("begin", params) + + def pyright_report_progress(params: object | None) -> None: + """Handles the ``pyright/reportProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("report", params) + + def pyright_end_progress(params: object | None) -> None: + """Handles the ``pyright/endProgress`` notification.""" + # delegating to the shared progress handler + handle_pyright_progress_notification("end", params) + def check_experimental_status(params: dict) -> None: """ Also listen for experimental/serverStatus as a backup signal @@ -170,6 +216,9 @@ class PyrightServer(SolidLanguageServer): self.server.on_notification("window/logMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("pyright/beginProgress", pyright_begin_progress) + self.server.on_notification("pyright/reportProgress", pyright_report_progress) + self.server.on_notification("pyright/endProgress", pyright_end_progress) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 1e7ccd2e..a41deb89 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -7,13 +7,17 @@ import glob import logging import os import pathlib +import platform import shutil import threading from time import sleep from typing import Any +from overrides import override + +from solidlsp import ls_types from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command -from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer +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 @@ -70,6 +74,7 @@ class SolidityLanguageServer(SolidLanguageServer): is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." solidity_language_server_version = self._custom_settings.get("solidity_language_server_version", "0.8.4") + forge_version = self._custom_settings.get("forge_version", "1.5.1") npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -84,17 +89,29 @@ class SolidityLanguageServer(SolidLanguageServer): ), platform_id="any", ), + RuntimeDependency( + id="forge", + description="Foundry forge CLI", + # the @foundry-rs/forge meta-package's postinstall uses POSIX env-var + # syntax that fails on Windows cmd.exe, so we install the platform-specific + # subpackage directly to bypass it + command=build_npm_install_command(self._get_forge_npm_package(), forge_version, npm_registry), + platform_id="any", + ), ] ) solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp") - solidity_executable_path = os.path.join(solidity_ls_dir, "node_modules", ".bin", "nomicfoundation-solidity-language-server") + managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") + solidity_executable_path = os.path.join(managed_bin_dir, "nomicfoundation-solidity-language-server") + forge_executable_path = os.path.join(managed_bin_dir, "forge") if os.name == "nt": solidity_executable_path += ".cmd" + forge_executable_path += ".cmd" - if not os.path.exists(solidity_executable_path): - log.info(f"Solidity Language Server executable not found at {solidity_executable_path}. Installing...") + if not os.path.exists(solidity_executable_path) or not os.path.exists(forge_executable_path): + log.info("Solidity Language Server dependencies missing. Installing...") deps.install(solidity_ls_dir) log.info("Solidity language server dependencies installed successfully.") @@ -106,6 +123,33 @@ class SolidityLanguageServer(SolidLanguageServer): return solidity_executable_path + @staticmethod + def _get_forge_npm_package() -> str: + """ + Returns the platform-specific @foundry-rs forge npm subpackage name. Installing + the meta-package fails on Windows due to a POSIX-only postinstall script. + """ + machine = platform.machine().lower() + if machine in ("x86_64", "amd64"): + arch = "amd64" + elif machine in ("arm64", "aarch64"): + arch = "arm64" + else: + raise RuntimeError(f"Unsupported architecture for foundry forge: {machine}") + + if os.name == "nt": + if arch != "amd64": + raise RuntimeError(f"foundry forge does not publish a Windows {arch} npm package") + return "@foundry-rs/forge-win32-amd64" + if platform.system() == "Darwin": + return f"@foundry-rs/forge-darwin-{arch}" + return f"@foundry-rs/forge-linux-{arch}" + + def create_launch_command_env(self) -> dict[str, str]: + solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp") + managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") + return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] @@ -225,3 +269,83 @@ class SolidityLanguageServer(SolidLanguageServer): log.info("No .sol files found; skipping indexing wait") log.info("Solidity language server initialization complete") + + @override + 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]: + self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) + uri = self._get_validation_document_uri(relative_file_path) + diagnostics_before_request = self._get_published_diagnostics_generation(uri) + + absolute_path = pathlib.Path(self.repository_root_path, relative_file_path) + contents = absolute_path.read_text(encoding=self._encoding) + lines = contents.split("\n") + end_position = { + "line": len(lines) - 1, + "character": len(lines[-1]), + } + + self.server.notify.did_open_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + LSPConstants.LANGUAGE_ID: self.language_id, + LSPConstants.VERSION: 0, + LSPConstants.TEXT: contents, + } + } + ) + try: + self.server.notify.did_change_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + LSPConstants.VERSION: 1, + }, + LSPConstants.CONTENT_CHANGES: [ # type: ignore + { + LSPConstants.RANGE: { + "start": {"line": 0, "character": 0}, + "end": end_position, + }, + "text": contents, + } + ], + } + ) + 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, + ) + finally: + self.server.notify.did_close_text_document( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + LSPConstants.URI: uri, + } + } + ) + + if diagnostics is None: + return [] + + return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) + + def _get_validation_document_uri(self, relative_file_path: str) -> str: + absolute_path = pathlib.Path(self.repository_root_path, relative_file_path) + + if os.name != "nt": + return absolute_path.as_uri() + + path = absolute_path.as_posix() + if len(path) >= 2 and path[1] == ":": + path = f"{path[0].lower()}%3A{path[2:]}" + + return f"file:///{path.lstrip('/')}" diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 4ea04694..57a5cdfd 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -618,15 +618,49 @@ class SolidLanguageServer(ABC): 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[uri] = normalized_diagnostics - self._published_diagnostics_generation_by_uri[uri] = self._published_diagnostics_generation + 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(uri, -1) + return self._published_diagnostics_generation_by_uri.get(key, -1) def _wait_for_published_diagnostics( self, @@ -634,12 +668,13 @@ class SolidLanguageServer(ABC): 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(uri, -1) + current_generation = self._published_diagnostics_generation_by_uri.get(key, -1) if current_generation > after_generation: - return list(self._published_diagnostics.get(uri, [])) + return list(self._published_diagnostics.get(key, [])) remaining_timeout = deadline - perf_counter() if remaining_timeout <= 0: @@ -647,8 +682,9 @@ class SolidLanguageServer(ABC): 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(uri) + diagnostics = self._published_diagnostics.get(key) if diagnostics is None: return None return list(diagnostics) @@ -657,8 +693,17 @@ class SolidLanguageServer(ABC): 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"] - effective_end_line = end_line if end_line >= 0 else diagnostic_end_line - return diagnostic_start_line <= effective_end_line and diagnostic_end_line >= start_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: @@ -777,6 +822,16 @@ class SolidLanguageServer(ABC): """ 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. @@ -851,18 +906,22 @@ class SolidLanguageServer(ABC): pull_diagnostics_failed = False with self.open_file(relative_file_path): - try: - response = self.server.send.text_document_diagnostic( - { - LSPConstants.TEXT_DOCUMENT: { # type: ignore - LSPConstants.URI: uri, + 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( + { + LSPConstants.TEXT_DOCUMENT: { # type: ignore + 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 + ) + 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), ( @@ -875,7 +934,7 @@ class SolidLanguageServer(ABC): "severity": item["severity"], "message": item["message"], "range": item["range"], - "code": item["code"], # type: ignore + "code": item.get("code"), # type: ignore } if "source" in item: new_item["source"] = item["source"] diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index a577b849..974cdc4b 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -513,7 +513,7 @@ FIND_SYMBOL_REFERENCES_CASES = [ symbol_name="Calculator", expected_kind="Module", expected_file="Calculator.fs", - ).to_pytest_param(), + ).to_pytest_param(pytest.mark.xfail(reason="F# language server is unreliable")), FindSymbolCase( language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs" ).to_pytest_param(), diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py index 02aa3b9b..347f6022 100644 --- a/test/solidlsp/go/test_go_basic.py +++ b/test/solidlsp/go/test_go_basic.py @@ -71,9 +71,9 @@ class TestGoLanguageServer: implementing_symbols = language_server.request_implementing_symbols("main.go", *pos) assert implementing_symbols, "Expected implementing symbols for Greeter.FormatGreeting" assert any( - symbol.get("name") == "(ConsoleGreeter).FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") + symbol.get("name") == "FormatGreeting" and "main.go" in symbol["location"].get("relativePath", "") for symbol in implementing_symbols - ), f"Expected ConsoleGreeter.FormatGreeting symbol, got: {implementing_symbols}" + ), f"Expected FormatGreeting symbol, got: {implementing_symbols}" def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: From 15858e6cd99c30ae26cc5bc038d1be9b58e9b8d4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 21:58:10 +0200 Subject: [PATCH 38/48] Refactoring: Introduce base class EditingToolWithDiagnostics --- src/serena/tools/file_tools.py | 14 +- src/serena/tools/symbol_tools.py | 7 +- src/serena/tools/tools_base.py | 212 ++++++++++++++++--------------- 3 files changed, 122 insertions(+), 111 deletions(-) diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 42923a03..4572f2fc 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Literal from serena.code_editor import EditedFilePath -from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional +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 @@ -49,7 +49,7 @@ class ReadFileTool(Tool): return self._limit_length(result, max_answer_chars) -class CreateTextFileTool(Tool, ToolMarkerCanEdit): +class CreateTextFileTool(EditingToolWithDiagnostics): """ Creates/overwrites a file in the project directory. """ @@ -164,13 +164,11 @@ class FindFileTool(Tool): return result -class ReplaceContentTool(Tool, ToolMarkerCanEdit): +class ReplaceContentTool(EditingToolWithDiagnostics): """ Replaces content in a file (optionally using regular expressions). """ - _CAPTURE_DIAGNOSTICS: bool = False - def apply( self, relative_path: str, @@ -237,7 +235,7 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Deletes a range of lines within a file. """ @@ -268,7 +266,7 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Replaces a range of lines within a file with new content. """ @@ -306,7 +304,7 @@ class ReplaceLinesTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class InsertAtLineTool(Tool, ToolMarkerCanEdit, ToolMarkerOptional): +class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): """ Inserts content at a given line in a file. """ diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 2a44a14e..036c1d71 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -13,6 +13,7 @@ from serena.code_editor import EditedFilePath from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, + EditingToolWithDiagnostics, Tool, ToolMarkerSymbolicEdit, ToolMarkerSymbolicRead, @@ -771,7 +772,7 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption return self._limit_length(result, max_answer_chars) -class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): +class ReplaceSymbolBodyTool(EditingToolWithDiagnostics): """ Replaces the full definition of a symbol using the language server backend. """ @@ -810,7 +811,7 @@ class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): +class InsertAfterSymbolTool(EditingToolWithDiagnostics): """ Inserts content after the end of the definition of a given symbol. """ @@ -841,7 +842,7 @@ class InsertAfterSymbolTool(Tool, ToolMarkerSymbolicEdit): return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) -class InsertBeforeSymbolTool(Tool, ToolMarkerSymbolicEdit): +class InsertBeforeSymbolTool(EditingToolWithDiagnostics): """ Inserts content before the beginning of the definition of a given symbol. """ diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 7ef28c3c..ca8e5a49 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -165,8 +165,6 @@ class Tool(Component): _last_tool_call_client_str: str | None = None """We can only get the client info from within a tool call. Each tool call will update this variable.""" - _ENABLE_DIAGNOSTICS: bool = False - def __init__(self, agent: "SerenaAgent"): super().__init__(agent) @@ -326,6 +324,118 @@ class Tool(Component): def is_symbolic(self) -> bool: return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) + 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 + """ + session_id = "global" + if mcp_ctx is not None: + try: + session_id = "%x" % id(mcp_ctx.session) + client_params = mcp_ctx.session.client_params + if client_params is not None: + client_info = cast(Implementation, client_params.clientInfo) + client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version + if client_str != self.get_last_tool_call_client_str(): + log.debug(f"Updating client info: {client_info}") + self.set_last_tool_call_client_str(client_str) + except Exception as e: + log.info(f"Failed to get client info: {e}.") + + def task() -> str: + apply_fn = self.get_apply_fn() + + 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}" + + 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: " + + f"{self.agent.serena_config.project_names}" + ) + + # construct apply kwargs, adding session_id if the tool is session-aware + apply_kwargs = dict(kwargs) + if self._is_session_aware: + apply_kwargs["session_id"] = session_id + + # apply the actual tool + try: + result = apply_fn(**apply_kwargs) + except SolidLSPException as e: + if e.is_language_server_terminated(): + affected_language = e.get_affected_language() + if affected_language is not None: + log.error( + f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." + ) + self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language) + result = apply_fn(**apply_kwargs) + else: + log.error( + f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying." + ) + raise + else: + raise + + # record tool usage + self.agent.record_tool_usage(apply_kwargs, result, self) + + 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 + + if log_call: + log.info(f"Result: {result}") + + try: + ls_manager = self.agent.get_language_server_manager() + if ls_manager is not None: + ls_manager.save_all_caches() + except Exception as e: + log.error(f"Error saving language server cache: {e}") + + return result + + # execute the tool in the agent's task executor, with timeout + 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}" + log.error(msg) + return msg + + @staticmethod + def _to_json(x: Any) -> str: + return json.dumps(x, ensure_ascii=False) + + +class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): + """ + Base class for editing tools that want to capture and report changes in LSP diagnostics before and after the edit. + """ + + _ENABLE_DIAGNOSTICS: bool = False + """ + Global flag to enable/disable diagnostics for LSP-based editing tools derived from this class. + The feature is currently disabled, because per-edit diagnostics are a questionable feature, since individual + edits often intentionally introduce diagnostics (e.g. function signature mismatches or even syntax errors) that + are then resolved in subsequent edits. + """ + @staticmethod def _diagnostic_code_repr(code: Any) -> str | None: if code is None: @@ -500,104 +610,6 @@ class Tool(Component): return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}" - 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 - """ - session_id = "global" - if mcp_ctx is not None: - try: - session_id = "%x" % id(mcp_ctx.session) - client_params = mcp_ctx.session.client_params - if client_params is not None: - client_info = cast(Implementation, client_params.clientInfo) - client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version - if client_str != self.get_last_tool_call_client_str(): - log.debug(f"Updating client info: {client_info}") - self.set_last_tool_call_client_str(client_str) - except Exception as e: - log.info(f"Failed to get client info: {e}.") - - def task() -> str: - apply_fn = self.get_apply_fn() - - 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}" - - 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: " - + f"{self.agent.serena_config.project_names}" - ) - - # construct apply kwargs, adding session_id if the tool is session-aware - apply_kwargs = dict(kwargs) - if self._is_session_aware: - apply_kwargs["session_id"] = session_id - - # apply the actual tool - try: - result = apply_fn(**apply_kwargs) - except SolidLSPException as e: - if e.is_language_server_terminated(): - affected_language = e.get_affected_language() - if affected_language is not None: - log.error( - f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..." - ) - self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language) - result = apply_fn(**apply_kwargs) - else: - log.error( - f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying." - ) - raise - else: - raise - - # record tool usage - self.agent.record_tool_usage(apply_kwargs, result, self) - - 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 - - if log_call: - log.info(f"Result: {result}") - - try: - ls_manager = self.agent.get_language_server_manager() - if ls_manager is not None: - ls_manager.save_all_caches() - except Exception as e: - log.error(f"Error saving language server cache: {e}") - - return result - - # execute the tool in the agent's task executor, with timeout - 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}" - log.error(msg) - return msg - - @staticmethod - def _to_json(x: Any) -> str: - return json.dumps(x, ensure_ascii=False) - class EditedFileContext: """ From 061020ce226e7f0790ee3baffe97c6f2b4bdef82 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 23:18:47 +0200 Subject: [PATCH 39/48] Refactor EditingToolWithDiagnostics * Add context manager to simplify usage, improving output handling * Add util module ls_diagnostics, reifying DiagnosticsDiff --- scripts/demo_diagnostics.py | 12 +- src/serena/code_editor.py | 8 +- src/serena/tools/file_tools.py | 100 +++++-------- src/serena/tools/symbol_tools.py | 48 +++---- src/serena/tools/tools_base.py | 227 +++++------------------------- src/serena/util/ls_diagnostics.py | 202 ++++++++++++++++++++++++++ test/serena/test_serena_agent.py | 69 +++++---- 7 files changed, 343 insertions(+), 323 deletions(-) create mode 100644 src/serena/util/ls_diagnostics.py diff --git a/scripts/demo_diagnostics.py b/scripts/demo_diagnostics.py index 7aa85361..2e5cd30e 100644 --- a/scripts/demo_diagnostics.py +++ b/scripts/demo_diagnostics.py @@ -16,7 +16,13 @@ from serena.agent import SerenaAgent from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig from serena.constants import REPO_ROOT from serena.project import Project -from serena.tools import CreateTextFileTool, GetDiagnosticsForFileTool, GetDiagnosticsForSymbolTool, ReplaceContentTool, Tool +from serena.tools import ( + CreateTextFileTool, + EditingToolWithDiagnostics, + GetDiagnosticsForFileTool, + GetDiagnosticsForSymbolTool, + ReplaceContentTool, +) from solidlsp.ls_config import Language SEPARATOR = "=" * 80 @@ -69,7 +75,7 @@ def parse_edit_diagnostics_result(result: str) -> dict: if __name__ == "__main__": - Tool._ENABLE_DIAGNOSTICS = True + EditingToolWithDiagnostics.ENABLE_DIAGNOSTICS = True temp_dir = Path(tempfile.mkdtemp(prefix="serena_demo_", dir=REPO_PATH)) temp_file = temp_dir / "demo_temp_diagnostics.py" @@ -166,5 +172,5 @@ if __name__ == "__main__": assert "missing_one" in diagnostics_after_second_edit_json, diagnostics_after_second_edit assert "missing_two" in diagnostics_after_second_edit_json, diagnostics_after_second_edit finally: - agent.shutdown() shutil.rmtree(temp_dir, ignore_errors=True) + agent.shutdown() diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 7d52f2b8..ec834273 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -4,7 +4,6 @@ import os from abc import ABC, abstractmethod from collections.abc import Iterable, Iterator, Reversible from contextlib import contextmanager -from dataclasses import dataclass from typing import Generic, TypeVar, cast from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient @@ -14,17 +13,12 @@ from solidlsp.ls import LSPFileBuffer from solidlsp.ls_utils import PathUtils, TextUtils from .project import Project +from .util.ls_diagnostics import EditedFilePath log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) -@dataclass(frozen=True) -class EditedFilePath: - before_relative_path: str - after_relative_path: str - - class CodeEditor(Generic[TSymbol], ABC): def __init__(self, project: Project) -> None: self.project_root = project.project_root diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 4572f2fc..4b4313eb 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -12,7 +12,6 @@ from fnmatch import fnmatch from pathlib import Path from typing import Literal -from serena.code_editor import EditedFilePath 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 @@ -62,30 +61,27 @@ class CreateTextFileTool(EditingToolWithDiagnostics): :param content: the (appropriately encoded) content to write to the file :return: a message indicating success or failure """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + # validating the destination path + project_root = self.get_project_root() + abs_path = (Path(project_root) / relative_path).resolve() + will_overwrite_existing = abs_path.exists() - # validating the destination path - project_root = self.get_project_root() - abs_path = (Path(project_root) / relative_path).resolve() - will_overwrite_existing = abs_path.exists() + if will_overwrite_existing: + self.project.validate_relative_path(relative_path, require_not_ignored=True) + else: + assert abs_path.is_relative_to(self.get_project_root()), ( + f"Cannot create file outside of the project directory, got {relative_path=}" + ) - if will_overwrite_existing: - self.project.validate_relative_path(relative_path, require_not_ignored=True) - else: - assert abs_path.is_relative_to(self.get_project_root()), ( - f"Cannot create file outside of the project directory, got {relative_path=}" - ) + # writing the file + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) + answer = f"File created: {relative_path}." + if will_overwrite_existing: + answer += " Overwrote existing file." - # writing the file - abs_path.parent.mkdir(parents=True, exist_ok=True) - abs_path.write_text(content, encoding=self.project.project_config.encoding, newline=self.project.line_ending.newline_str) - answer = f"File created: {relative_path}." - if will_overwrite_existing: - answer += " Overwrote existing file." - - return self._format_lsp_edit_result_with_new_diagnostics(answer, edited_file_paths, diagnostics_snapshot) + return diagnostics_context.format_result(answer) class ListDirTool(Tool): @@ -220,19 +216,14 @@ class ReplaceContentTool(EditingToolWithDiagnostics): Performs the replacement, with additional options not exposed in the tool. This function can be used internally by other tools. """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the content replacement - self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) - with EditedFileContext(relative_path, self.create_code_editor()) as context: - original_content = context.get_original_content() - replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) - updated_content = replacer.replace(original_content, needle, repl) - context.set_updated_content(updated_content) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) + with EditedFileContext(relative_path, self.create_code_editor()) as context: + original_content = context.get_original_content() + replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) + updated_content = replacer.replace(original_content, needle, repl) + context.set_updated_content(updated_content) + return diagnostics_context.format_result(SUCCESS_RESULT) class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): @@ -255,15 +246,10 @@ class DeleteLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): :param start_line: the 0-based index of the first line to be deleted :param end_line: the 0-based index of the last line to be deleted """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the edit - code_editor = self.create_code_editor() - code_editor.delete_lines(relative_path, start_line, end_line) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + return diagnostics_context.format_result(SUCCESS_RESULT) class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): @@ -292,16 +278,12 @@ class ReplaceLinesTool(EditingToolWithDiagnostics, ToolMarkerOptional): if not content.endswith("\n"): content += "\n" - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.delete_lines(relative_path, start_line, end_line) + code_editor.insert_at_line(relative_path, start_line, content) - # applying the replacement - code_editor = self.create_code_editor() - code_editor.delete_lines(relative_path, start_line, end_line) - code_editor.insert_at_line(relative_path, start_line, content) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + return diagnostics_context.format_result(SUCCESS_RESULT) class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): @@ -329,15 +311,11 @@ class InsertAtLineTool(EditingToolWithDiagnostics, ToolMarkerOptional): if not content.endswith("\n"): content += "\n" - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_at_line(relative_path, line, content) - # applying the insertion - code_editor = self.create_code_editor() - code_editor.insert_at_line(relative_path, line, content) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + return diagnostics_context.format_result(SUCCESS_RESULT) class SearchForPatternTool(Tool): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 036c1d71..7f9c17f5 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -9,7 +9,6 @@ from collections import Counter, defaultdict from collections.abc import Sequence from typing import Any -from serena.code_editor import EditedFilePath from serena.symbol import LanguageServerSymbol, LanguageServerSymbolDictGrouper from serena.tools import ( SUCCESS_RESULT, @@ -796,19 +795,14 @@ class ReplaceSymbolBodyTool(EditingToolWithDiagnostics): in the programming language, including e.g. the signature line for functions. IMPORTANT: The body does NOT include any preceding docstrings/comments or imports, in particular. """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the symbol replacement - code_editor = self.create_code_editor() - code_editor.replace_body( - name_path, - relative_file_path=relative_path, - body=body, - ) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.replace_body( + name_path, + relative_file_path=relative_path, + body=body, + ) + return diagnostics_context.format_result(SUCCESS_RESULT) class InsertAfterSymbolTool(EditingToolWithDiagnostics): @@ -831,15 +825,10 @@ class InsertAfterSymbolTool(EditingToolWithDiagnostics): :param body: the body/content to be inserted. The inserted code shall begin with the next line after the symbol. """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the insertion - code_editor = self.create_code_editor() - code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_after_symbol(name_path, relative_file_path=relative_path, body=body) + return diagnostics_context.format_result(SUCCESS_RESULT) class InsertBeforeSymbolTool(EditingToolWithDiagnostics): @@ -862,15 +851,10 @@ class InsertBeforeSymbolTool(EditingToolWithDiagnostics): :param relative_path: the relative path to the file containing the symbol :param body: the body/content to be inserted before the line in which the referenced symbol is defined """ - # capturing diagnostics before the edit - edited_file_paths = [EditedFilePath(relative_path, relative_path)] - diagnostics_snapshot = self._capture_published_lsp_diagnostics_snapshot(edited_file_paths) - - # applying the insertion - code_editor = self.create_code_editor() - code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) - - return self._format_lsp_edit_result_with_new_diagnostics(SUCCESS_RESULT, edited_file_paths, diagnostics_snapshot) + with self.DiagnosticsContext(self, relative_path) as diagnostics_context: + code_editor = self.create_code_editor() + code_editor.insert_before_symbol(name_path, relative_file_path=relative_path, body=body) + return diagnostics_context.format_result(SUCCESS_RESULT) class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index ca8e5a49..11d92dce 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass from functools import cached_property from types import TracebackType -from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast +from typing import TYPE_CHECKING, Any, Optional, Protocol, Self, TypeVar, cast from mcp import Implementation from mcp.server.fastmcp import Context @@ -18,13 +18,12 @@ from serena.project import MemoriesManager, Project from serena.prompt_factory import PromptFactory from serena.util.class_decorators import singleton from serena.util.inspection import iter_subclasses -from solidlsp import ls_types +from serena.util.ls_diagnostics import DiagnosticsDiff, EditedFilePath, PublishedDiagnosticsSnapshot from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity if TYPE_CHECKING: from serena.agent import SerenaAgent - from serena.code_editor import CodeEditor, EditedFilePath, LanguageServerCodeEditor + from serena.code_editor import CodeEditor, LanguageServerCodeEditor from serena.symbol import LanguageServerSymbolRetriever log = logging.getLogger(__name__) @@ -32,24 +31,6 @@ T = TypeVar("T") SUCCESS_RESULT = "OK" -@dataclass(frozen=True) -class DiagnosticIdentity: - message: str - start_line: int - start_character: int - end_line: int - end_character: int - severity: int | None - code_repr: str | None - source: str | None - - -@dataclass -class PublishedDiagnosticsSnapshot: - generation_by_after_path: dict[str, int] - warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] - - class Component(ABC): def __init__(self, agent: "SerenaAgent"): self.agent = agent @@ -428,7 +409,7 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): Base class for editing tools that want to capture and report changes in LSP diagnostics before and after the edit. """ - _ENABLE_DIAGNOSTICS: bool = False + ENABLE_DIAGNOSTICS: bool = False """ Global flag to enable/disable diagnostics for LSP-based editing tools derived from this class. The feature is currently disabled, because per-edit diagnostics are a questionable feature, since individual @@ -436,179 +417,47 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): are then resolved in subsequent edits. """ - @staticmethod - def _diagnostic_code_repr(code: Any) -> str | None: - if code is None: - return None - try: - return json.dumps(code, sort_keys=True, ensure_ascii=False) - except TypeError: - return repr(code) + DIAGNOSTICS_KEY = "diagnostics[warning-or-higher]" - @classmethod - def _diagnostic_identity(cls, diagnostic: ls_types.Diagnostic) -> DiagnosticIdentity: - diagnostic_range = diagnostic["range"] - start = diagnostic_range["start"] - end = diagnostic_range["end"] - return DiagnosticIdentity( - message=diagnostic["message"], - start_line=start["line"], - start_character=start["character"], - end_line=end["line"], - end_character=end["character"], - severity=diagnostic.get("severity"), - code_repr=cls._diagnostic_code_repr(diagnostic.get("code")), - source=diagnostic.get("source"), - ) + class DiagnosticsContext: + def __init__(self, tool: "EditingToolWithDiagnostics", *edited_relative_paths: str) -> None: + self._tool = tool + self._is_diagnostics_enabled = tool.ENABLE_DIAGNOSTICS and tool.agent.is_using_language_server() + self._edited_files = [EditedFilePath(path, path) for path in edited_relative_paths] + self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None + self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None + if self._is_diagnostics_enabled: + symbol_retriever = tool.create_language_server_symbol_retriever() + self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, symbol_retriever) - @staticmethod - def _diagnostic_severity_name(severity: int | None) -> str: - if severity is None: - return "Unknown" - try: - return DiagnosticSeverity(severity).name - except ValueError: - return f"Severity_{severity}" + def __enter__(self) -> Self: + return self - @staticmethod - def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: - result: dict[str, Any] = { - "message": diagnostic["message"], - "range": diagnostic["range"], - } - if "code" in diagnostic: - result["code"] = diagnostic["code"] - if "source" in diagnostic: - result["source"] = diagnostic["source"] - return result + def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore + pass - @classmethod - def _add_grouped_diagnostic( - cls, - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], - relative_path: str, - name_path: str, - diagnostic: ls_types.Diagnostic, - ) -> None: - severity_name = cls._diagnostic_severity_name(diagnostic.get("severity")) - grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( - cls._diagnostic_output_dict(diagnostic) - ) + def format_result( + self, + base_result: str, + ) -> str: + if not self._is_diagnostics_enabled: + return base_result - def _capture_published_lsp_diagnostics_snapshot( - self, - edited_file_paths: Iterable["EditedFilePath"], - ) -> PublishedDiagnosticsSnapshot | None: - if not self._ENABLE_DIAGNOSTICS: - return None - if self.agent.get_language_backend() != LanguageBackend.LSP: - return None + if self._before_edit_diagnostics_snapshot is None: + return base_result - # collecting diagnostics state before the edit - symbol_retriever = self.create_language_server_symbol_retriever() - generation_by_after_path: dict[str, int] = {} - warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {} + assert self._symbol_retriever is not None + diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever) + grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics() - for edited_file_path in edited_file_paths: - try: - language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) - except Exception: - return None - - generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation( - edited_file_path.after_relative_path - ) - - cached_diagnostics = language_server.get_cached_published_text_document_diagnostics( - edited_file_path.before_relative_path, - min_severity=2, - ) - if cached_diagnostics is None: - try: - cached_diagnostics = language_server.request_text_document_diagnostics( - edited_file_path.before_relative_path, - min_severity=2, - ) - except Exception: - cached_diagnostics = [] - warning_identities_by_before_path[edited_file_path.before_relative_path] = { - self._diagnostic_identity(diagnostic) for diagnostic in cached_diagnostics or [] - } - - return PublishedDiagnosticsSnapshot( - generation_by_after_path=generation_by_after_path, - warning_identities_by_before_path=warning_identities_by_before_path, - ) - - def _format_lsp_edit_result_with_new_diagnostics( - self, - default_result: str, - edited_file_paths: Iterable["EditedFilePath"], - before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None, - ) -> str: - if not self._ENABLE_DIAGNOSTICS: - return default_result - - # TODO: this is weird, it works because before_edit_diagnostics_snapshot is only None when diagnostics - # are disabled, not when they are empty. A part of the general design flaws introduced by agent-generated code - if before_edit_diagnostics_snapshot is None or self.agent.get_language_backend() != LanguageBackend.LSP: - return default_result - - # collecting diagnostics state after the edit - symbol_retriever = self.create_language_server_symbol_retriever() - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} - saw_diagnostics_result = False - - for edited_file_path in edited_file_paths: - try: - language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) - except Exception: - return default_result - - published_diagnostics = language_server.request_published_text_document_diagnostics( - relative_file_path=edited_file_path.after_relative_path, - after_generation=before_edit_diagnostics_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1), - timeout=2.5, - min_severity=2, - allow_cached=True, - ) - if not published_diagnostics: - try: - published_diagnostics = language_server.request_text_document_diagnostics( - edited_file_path.after_relative_path, - min_severity=2, - ) - except Exception: - published_diagnostics = None - if published_diagnostics is None: - continue - - saw_diagnostics_result = True - existing_warning_identities = before_edit_diagnostics_snapshot.warning_identities_by_before_path.get( - edited_file_path.before_relative_path, set() - ) - new_warning_identities: set[DiagnosticIdentity] = set() - - for diagnostic in published_diagnostics: - diagnostic_identity = self._diagnostic_identity(diagnostic) - if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities: - continue - new_warning_identities.add(diagnostic_identity) - - diagnostic_start = diagnostic["range"]["start"] - owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( - relative_file_path=edited_file_path.after_relative_path, - line=diagnostic_start["line"], - column=diagnostic_start["character"], - ) - name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" - self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic) - - # preserving the normal success result when no new diagnostics were introduced - if not saw_diagnostics_result or not grouped_result: - return default_result - - return f"Edit introduced new warning-or-higher diagnostics: {self._to_json(grouped_result)}" + if not grouped_diagnostics: + return base_result + else: + result_dict = { + "result": base_result, + EditingToolWithDiagnostics.DIAGNOSTICS_KEY: grouped_diagnostics, + } + return self._tool._to_json(result_dict) class EditedFileContext: diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py new file mode 100644 index 00000000..6df5ce34 --- /dev/null +++ b/src/serena/util/ls_diagnostics.py @@ -0,0 +1,202 @@ +import json +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from solidlsp import ls_types +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity + +if TYPE_CHECKING: + from serena.symbol import LanguageServerSymbolRetriever + + +@dataclass(frozen=True) +class EditedFilePath: + before_relative_path: str + after_relative_path: str + + +@dataclass(frozen=True) +class DiagnosticIdentity: + message: str + start_line: int + start_character: int + end_line: int + end_character: int + severity: int | None + code_repr: str | None + source: str | None + + @classmethod + def from_diagnostic(cls, diagnostic: ls_types.Diagnostic) -> "DiagnosticIdentity": + diagnostic_range = diagnostic["range"] + start = diagnostic_range["start"] + end = diagnostic_range["end"] + return DiagnosticIdentity( + message=diagnostic["message"], + start_line=start["line"], + start_character=start["character"], + end_line=end["line"], + end_character=end["character"], + severity=diagnostic.get("severity"), + code_repr=cls._diagnostic_code_repr(diagnostic.get("code")), + source=diagnostic.get("source"), + ) + + @staticmethod + def _diagnostic_code_repr(code: Any) -> str | None: + if code is None: + return None + try: + return json.dumps(code, sort_keys=True, ensure_ascii=False) + except TypeError: + return repr(code) + + +class PublishedDiagnosticsSnapshot: + def __init__(self, edited_file_paths: Iterable[EditedFilePath], symbol_retriever: "LanguageServerSymbolRetriever"): + generation_by_after_path: dict[str, int] = {} + warning_identities_by_before_path: dict[str, set[DiagnosticIdentity]] = {} + + for edited_file_path in edited_file_paths: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except Exception: + continue + + generation_by_after_path[edited_file_path.after_relative_path] = language_server.get_published_diagnostics_generation( + edited_file_path.after_relative_path + ) + + cached_diagnostics = language_server.get_cached_published_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + if cached_diagnostics is None: + try: + cached_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.before_relative_path, + min_severity=2, + ) + except Exception: + cached_diagnostics = [] + warning_identities_by_before_path[edited_file_path.before_relative_path] = { + DiagnosticIdentity.from_diagnostic(diagnostic) for diagnostic in cached_diagnostics or [] + } + + self.generation_by_after_path = generation_by_after_path + self.warning_identities_by_before_path = warning_identities_by_before_path + + +class DiagnosticsDiff: + def __init__( + self, + before_snapshot: PublishedDiagnosticsSnapshot, + edited_files: Iterable[EditedFilePath], + symbol_retriever: "LanguageServerSymbolRetriever", + ): + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + + for edited_file_path in edited_files: + try: + language_server = symbol_retriever.get_language_server(edited_file_path.after_relative_path) + except: + continue + + published_diagnostics = language_server.request_published_text_document_diagnostics( + relative_file_path=edited_file_path.after_relative_path, + after_generation=before_snapshot.generation_by_after_path.get(edited_file_path.after_relative_path, -1), + timeout=2.5, + min_severity=2, + allow_cached=True, + ) + if not published_diagnostics: + try: + published_diagnostics = language_server.request_text_document_diagnostics( + edited_file_path.after_relative_path, + min_severity=2, + ) + except: + published_diagnostics = None + if published_diagnostics is None: + continue + + existing_warning_identities = before_snapshot.warning_identities_by_before_path.get( + edited_file_path.before_relative_path, set() + ) + new_warning_identities: set[DiagnosticIdentity] = set() + + for diagnostic in published_diagnostics: + diagnostic_identity = DiagnosticIdentity.from_diagnostic(diagnostic) + if diagnostic_identity in existing_warning_identities or diagnostic_identity in new_warning_identities: + continue + new_warning_identities.add(diagnostic_identity) + + diagnostic_start = diagnostic["range"]["start"] + owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( + relative_file_path=edited_file_path.after_relative_path, + line=diagnostic_start["line"], + column=diagnostic_start["character"], + ) + name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" + self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic) + + self._grouped_result = grouped_result + + def get_grouped_diagnostics(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]: + """ + Returns a nested dictionary of the form: + { + relative_file_path: { + severity_name: { + name_path: [ + diagnostic_dict, + ... + ], + ... + }, + ... + }, + ... + } + where: + - relative_file_path is the relative path of the file containing the diagnostic + - severity_name is the name of the diagnostic severity (e.g. "Warning", "Error") + - name_path is the name path of the symbol that owns the diagnostic (or "" if no owner symbol was found) + - diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source + """ + return self._grouped_result + + @classmethod + def _add_grouped_diagnostic( + cls, + grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], + relative_path: str, + name_path: str, + diagnostic: ls_types.Diagnostic, + ) -> None: + severity_name = cls._diagnostic_severity_name(diagnostic.get("severity")) + grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + cls._diagnostic_output_dict(diagnostic) + ) + + @staticmethod + def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + @staticmethod + def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 974cdc4b..b3e2427b 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -18,6 +18,7 @@ from serena.project import Project from serena.tools import ( SUCCESS_RESULT, ActivateProjectTool, + EditingToolWithDiagnostics, FindDefiningSymbolAtLocationTool, FindDefiningSymbolTool, FindImplementationsTool, @@ -812,9 +813,9 @@ def read_project_file(project: Project, relative_path: str) -> str: def parse_edit_diagnostics_result(result: str) -> dict: """Utility function to parse the diagnostic payload returned by edit tools.""" - prefix = "Edit introduced new warning-or-higher diagnostics: " - assert result.startswith(prefix), result - return json.loads(result[len(prefix) :]) + assert EditingToolWithDiagnostics.DIAGNOSTICS_KEY in result + d = json.loads(result) + return d[EditingToolWithDiagnostics.DIAGNOSTICS_KEY] @contextmanager @@ -1201,21 +1202,24 @@ class TestSerenaAgent: """Tests that file-level edits report newly introduced diagnostics.""" relative_path = os.path.join("test_repo", "services.py") replace_content_tool = serena_agent.get_tool(ReplaceContentTool) - replace_content_tool._ENABLE_DIAGNOSTICS = True + try: + replace_content_tool.ENABLE_DIAGNOSTICS = True - with project_file_modification_context(serena_agent, relative_path): - result = replace_content_tool.apply( - relative_path=relative_path, - needle="return container", - repl="return missing_container", - mode="literal", - ) + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + relative_path=relative_path, + needle="return container", + repl="return missing_container", + mode="literal", + ) - diagnostics = parse_edit_diagnostics_result(result) - relative_path_result = diagnostics[relative_path] - diagnostic_messages = json.dumps(relative_path_result) - assert "missing_container" in diagnostic_messages - assert "create_service_container" in diagnostic_messages + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + finally: + replace_content_tool.ENABLE_DIAGNOSTICS = False @pytest.mark.parametrize( "serena_agent", @@ -1229,23 +1233,26 @@ class TestSerenaAgent: """Tests that symbol-level edits report newly introduced diagnostics.""" relative_path = os.path.join("test_repo", "services.py") replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) - replace_symbol_body_tool._ENABLE_DIAGNOSTICS = True + try: + replace_symbol_body_tool.ENABLE_DIAGNOSTICS = True - with project_file_modification_context(serena_agent, relative_path): - result = replace_symbol_body_tool.apply( - name_path="create_service_container", - relative_path=relative_path, - body=""" -def create_service_container() -> dict[str, Any]: - return missing_container -""", - ) + with project_file_modification_context(serena_agent, relative_path): + result = replace_symbol_body_tool.apply( + name_path="create_service_container", + relative_path=relative_path, + body=""" + def create_service_container() -> dict[str, Any]: + return missing_container + """, + ) - diagnostics = parse_edit_diagnostics_result(result) - relative_path_result = diagnostics[relative_path] - diagnostic_messages = json.dumps(relative_path_result) - assert "missing_container" in diagnostic_messages - assert "create_service_container" in diagnostic_messages + diagnostics = parse_edit_diagnostics_result(result) + relative_path_result = diagnostics[relative_path] + diagnostic_messages = json.dumps(relative_path_result) + assert "missing_container" in diagnostic_messages + assert "create_service_container" in diagnostic_messages + finally: + replace_symbol_body_tool.ENABLE_DIAGNOSTICS = False @pytest.mark.parametrize( "serena_agent", From 5ab912ca0bd66a1edf7a5479fd7e6e584643981a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 29 Apr 2026 23:29:14 +0200 Subject: [PATCH 40/48] CodeEditor: Remove unused management of edited files Management was unnecessarily introduced by AI in 133e1be2a780ee875a9b7d739e076b85d81b29fd --- src/serena/code_editor.py | 47 ---------------------------------- src/serena/tools/tools_base.py | 4 +-- 2 files changed, 2 insertions(+), 49 deletions(-) diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index ec834273..2392d2ba 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -13,7 +13,6 @@ from solidlsp.ls import LSPFileBuffer from solidlsp.ls_utils import PathUtils, TextUtils from .project import Project -from .util.ls_diagnostics import EditedFilePath log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) @@ -24,7 +23,6 @@ class CodeEditor(Generic[TSymbol], ABC): self.project_root = project.project_root self.encoding = project.project_config.encoding self.newline = project.line_ending.newline_str - self._last_edited_file_paths: list[EditedFilePath] = [] class EditedFile(ABC): def __init__(self, relative_path: str) -> None: @@ -85,20 +83,6 @@ class CodeEditor(Generic[TSymbol], ABC): with open(abs_path, "w", encoding=self.encoding, newline=self.newline) as f: f.write(new_contents) - def _set_last_edited_file_paths(self, edited_file_paths: Iterable[EditedFilePath]) -> None: - unique_paths: list[EditedFilePath] = [] - seen_keys: set[tuple[str, str]] = set() - for edited_file_path in edited_file_paths: - key = (edited_file_path.before_relative_path, edited_file_path.after_relative_path) - if key in seen_keys: - continue - seen_keys.add(key) - unique_paths.append(edited_file_path) - self._last_edited_file_paths = unique_paths - - def get_last_edited_file_paths(self) -> list[EditedFilePath]: - return list(self._last_edited_file_paths) - @abstractmethod def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol: """ @@ -130,8 +114,6 @@ class CodeEditor(Generic[TSymbol], ABC): edited_file.delete_text_between_positions(start_pos, end_pos) edited_file.insert_text_at_position(start_pos, body) - self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) - @staticmethod def _count_leading_newlines(text: Iterable) -> int: cnt = 0 @@ -189,8 +171,6 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, col), body) - self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) - def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None: """ Inserts content before the symbol with the given name in the given file. @@ -219,8 +199,6 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body) - self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) - def insert_at_line(self, relative_path: str, line: int, content: str) -> None: """ Inserts content at the given line in the given file. @@ -232,8 +210,6 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, 0), content) - self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)]) - def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None: """ Deletes lines in the given file. @@ -250,8 +226,6 @@ class CodeEditor(Generic[TSymbol], ABC): end_pos = PositionInFile(line=end_line_for_delete, col=end_col) edited_file.delete_text_between_positions(start_pos, end_pos) - self._set_last_edited_file_paths([EditedFilePath(relative_path, relative_path)]) - def delete_symbol(self, name_path: str, relative_file_path: str) -> None: """ Deletes the symbol with the given name in the given file. @@ -262,8 +236,6 @@ class CodeEditor(Generic[TSymbol], ABC): with self.edited_file_context(relative_file_path) as edited_file: edited_file.delete_text_between_positions(start_pos, end_pos) - self._set_last_edited_file_paths([EditedFilePath(relative_file_path, relative_file_path)]) - @abstractmethod def rename_symbol(self, name_path: str, relative_path: str, new_name: str) -> str: pass @@ -320,10 +292,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): def apply(self) -> None: pass - @abstractmethod - def get_edited_file_paths(self) -> list[EditedFilePath]: - pass - class EditOperationFileTextEdits(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", file_uri: str, text_edits: list[ls_types.TextEdit]): self._code_editor = code_editor @@ -335,9 +303,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file) edited_file.apply_text_edits(self._text_edits) - def get_edited_file_paths(self) -> list[EditedFilePath]: - return [EditedFilePath(self._relative_path, self._relative_path)] - class EditOperationRenameFile(EditOperation): def __init__(self, code_editor: "LanguageServerCodeEditor", old_uri: str, new_uri: str): self._code_editor = code_editor @@ -349,9 +314,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): new_abs_path = os.path.join(self._code_editor.project_root, self._new_relative_path) os.rename(old_abs_path, new_abs_path) - def get_edited_file_paths(self) -> list[EditedFilePath]: - return [EditedFilePath(self._old_relative_path, self._new_relative_path)] - def _workspace_edit_to_edit_operations(self, workspace_edit: ls_types.WorkspaceEdit) -> list["LanguageServerCodeEditor.EditOperation"]: operations: list[LanguageServerCodeEditor.EditOperation] = [] @@ -381,15 +343,6 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): :return: number of edit operations applied """ operations = self._workspace_edit_to_edit_operations(workspace_edit) - - # recording the affected files - edited_file_paths: list[EditedFilePath] = [] - for operation in operations: - edited_file_paths.extend(operation.get_edited_file_paths()) - - self._set_last_edited_file_paths(edited_file_paths) - - # applying the edit operations for operation in operations: operation.apply() return len(operations) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 11d92dce..4348deb9 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -427,8 +427,8 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): self._before_edit_diagnostics_snapshot: PublishedDiagnosticsSnapshot | None = None self._symbol_retriever: Optional["LanguageServerSymbolRetriever"] | None = None if self._is_diagnostics_enabled: - symbol_retriever = tool.create_language_server_symbol_retriever() - self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, symbol_retriever) + self._symbol_retriever = tool.create_language_server_symbol_retriever() + self._before_edit_diagnostics_snapshot = PublishedDiagnosticsSnapshot(self._edited_files, self._symbol_retriever) def __enter__(self) -> Self: return self From 371d70f87ee0280d58ff7693ee4ad20c9b644293 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Apr 2026 11:43:23 +0200 Subject: [PATCH 41/48] Refactoring: Introduce abstraction GroupedDiagnostics, removing duplication --- src/serena/tools/symbol_tools.py | 68 +++-------------- src/serena/tools/tools_base.py | 2 +- src/serena/util/ls_diagnostics.py | 121 +++++++++++++++--------------- 3 files changed, 71 insertions(+), 120 deletions(-) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 7f9c17f5..d2626ae0 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -18,44 +18,8 @@ from serena.tools import ( ToolMarkerSymbolicRead, ) from serena.tools.tools_base import ToolMarkerOptional -from solidlsp import ls_types +from serena.util.ls_diagnostics import GroupedDiagnostics from solidlsp.ls_types import SymbolKind -from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity - -FILE_LEVEL_DIAGNOSTIC_BUCKET = "" - - -def _diagnostic_severity_name(severity: int | None) -> str: - if severity is None: - return "Unknown" - try: - return DiagnosticSeverity(severity).name - except ValueError: - return f"Severity_{severity}" - - -def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: - result: dict[str, Any] = { - "message": diagnostic["message"], - "range": diagnostic["range"], - } - if "code" in diagnostic: - result["code"] = diagnostic["code"] - if "source" in diagnostic: - result["source"] = diagnostic["source"] - return result - - -def _add_grouped_diagnostic( - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], - relative_path: str, - severity_name: str, - name_path: str, - diagnostic: ls_types.Diagnostic, -) -> None: - grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( - _diagnostic_output_dict(diagnostic) - ) def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]: @@ -663,7 +627,7 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): Gets diagnostics for a file, optionally restricted to a line range, grouped by file, severity, and containing symbol. """ - _ENABLE_DIAGNOSTICS: bool = True + FILE_LEVEL_DIAGNOSTIC_BUCKET = "" def apply( self, @@ -693,10 +657,10 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): min_severity=min_severity, ) - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + grouped_diagnostics = GroupedDiagnostics() for diagnostic in diagnostics: diag_range = diagnostic["range"]["start"] - name_path = FILE_LEVEL_DIAGNOSTIC_BUCKET + name_path = self.FILE_LEVEL_DIAGNOSTIC_BUCKET owner_symbol = symbol_retriever.find_diagnostic_owner_symbol( relative_file_path=relative_path, line=diag_range["line"], @@ -704,15 +668,9 @@ class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): ) if owner_symbol is not None: name_path = owner_symbol.get_name_path() - _add_grouped_diagnostic( - grouped_result, - relative_path=relative_path, - severity_name=_diagnostic_severity_name(diagnostic.get("severity")), - name_path=name_path, - diagnostic=diagnostic, - ) + grouped_diagnostics.add(relative_path, name_path, diagnostic) - result = self._to_json(grouped_result) + result = self._to_json(grouped_diagnostics.get_dict()) return self._limit_length(result, max_answer_chars) @@ -721,8 +679,6 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption Gets diagnostics for a symbol and, optionally, for symbols that reference it. """ - _ENABLE_DIAGNOSTICS: bool = True - def apply( self, name_path: str, @@ -752,22 +708,16 @@ class GetDiagnosticsForSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOption min_severity=min_severity, ) - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + grouped_diagnostics = GroupedDiagnostics() for symbol, diagnostics in diagnostics_by_symbol.items(): relative_path = symbol.relative_path if relative_path is None: continue symbol_name_path = symbol.get_name_path() for diagnostic in diagnostics: - _add_grouped_diagnostic( - grouped_result, - relative_path=relative_path, - severity_name=_diagnostic_severity_name(diagnostic.get("severity")), - name_path=symbol_name_path, - diagnostic=diagnostic, - ) + grouped_diagnostics.add(relative_path, symbol_name_path, diagnostic) - result = self._to_json(grouped_result) + result = self._to_json(grouped_diagnostics.get_dict()) return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 4348deb9..565a4055 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -448,7 +448,7 @@ class EditingToolWithDiagnostics(Tool, ToolMarkerCanEdit): assert self._symbol_retriever is not None diagnostics_diff = DiagnosticsDiff(self._before_edit_diagnostics_snapshot, self._edited_files, self._symbol_retriever) - grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics() + grouped_diagnostics = diagnostics_diff.get_grouped_diagnostics().get_dict() if not grouped_diagnostics: return base_result diff --git a/src/serena/util/ls_diagnostics.py b/src/serena/util/ls_diagnostics.py index 6df5ce34..13b505ea 100644 --- a/src/serena/util/ls_diagnostics.py +++ b/src/serena/util/ls_diagnostics.py @@ -88,6 +88,62 @@ class PublishedDiagnosticsSnapshot: self.warning_identities_by_before_path = warning_identities_by_before_path +class GroupedDiagnostics: + def __init__(self) -> None: + self._grouped_diagnostics: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + + def add(self, relative_path: str, name_path: str, diagnostic: ls_types.Diagnostic) -> None: + severity_name = self._diagnostic_severity_name(diagnostic.get("severity")) + self._grouped_diagnostics.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( + self._diagnostic_output_dict(diagnostic) + ) + + def get_dict(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]: + """ + Returns a nested dictionary of the form: + { + relative_file_path: { + severity_name: { + name_path: [ + diagnostic_dict, + ... + ], + ... + }, + ... + }, + ... + } + where: + - relative_file_path is the relative path of the file containing the diagnostic + - severity_name is the name of the diagnostic severity (e.g. "Warning", "Error") + - name_path is the name path of the symbol that owns the diagnostic (or "" if no owner symbol was found) + - diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source + """ + return self._grouped_diagnostics + + @staticmethod + def _diagnostic_severity_name(severity: int | None) -> str: + if severity is None: + return "Unknown" + try: + return DiagnosticSeverity(severity).name + except ValueError: + return f"Severity_{severity}" + + @staticmethod + def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: + result: dict[str, Any] = { + "message": diagnostic["message"], + "range": diagnostic["range"], + } + if "code" in diagnostic: + result["code"] = diagnostic["code"] + if "source" in diagnostic: + result["source"] = diagnostic["source"] + return result + + class DiagnosticsDiff: def __init__( self, @@ -95,7 +151,7 @@ class DiagnosticsDiff: edited_files: Iterable[EditedFilePath], symbol_retriever: "LanguageServerSymbolRetriever", ): - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]] = {} + grouped_diagnostics = GroupedDiagnostics() for edited_file_path in edited_files: try: @@ -139,64 +195,9 @@ class DiagnosticsDiff: column=diagnostic_start["character"], ) name_path = owner_symbol.get_name_path() if owner_symbol is not None else "" - self._add_grouped_diagnostic(grouped_result, edited_file_path.after_relative_path, name_path, diagnostic) + grouped_diagnostics.add(edited_file_path.after_relative_path, name_path, diagnostic) - self._grouped_result = grouped_result + self._grouped_diagnostics = grouped_diagnostics - def get_grouped_diagnostics(self) -> dict[str, dict[str, dict[str, list[dict[str, Any]]]]]: - """ - Returns a nested dictionary of the form: - { - relative_file_path: { - severity_name: { - name_path: [ - diagnostic_dict, - ... - ], - ... - }, - ... - }, - ... - } - where: - - relative_file_path is the relative path of the file containing the diagnostic - - severity_name is the name of the diagnostic severity (e.g. "Warning", "Error") - - name_path is the name path of the symbol that owns the diagnostic (or "" if no owner symbol was found) - - diagnostic_dict is a dictionary containing the diagnostic's message, range, and optionally code and source - """ - return self._grouped_result - - @classmethod - def _add_grouped_diagnostic( - cls, - grouped_result: dict[str, dict[str, dict[str, list[dict[str, Any]]]]], - relative_path: str, - name_path: str, - diagnostic: ls_types.Diagnostic, - ) -> None: - severity_name = cls._diagnostic_severity_name(diagnostic.get("severity")) - grouped_result.setdefault(relative_path, {}).setdefault(severity_name, {}).setdefault(name_path, []).append( - cls._diagnostic_output_dict(diagnostic) - ) - - @staticmethod - def _diagnostic_severity_name(severity: int | None) -> str: - if severity is None: - return "Unknown" - try: - return DiagnosticSeverity(severity).name - except ValueError: - return f"Severity_{severity}" - - @staticmethod - def _diagnostic_output_dict(diagnostic: ls_types.Diagnostic) -> dict[str, Any]: - result: dict[str, Any] = { - "message": diagnostic["message"], - "range": diagnostic["range"], - } - if "code" in diagnostic: - result["code"] = diagnostic["code"] - if "source" in diagnostic: - result["source"] = diagnostic["source"] - return result + def get_grouped_diagnostics(self) -> GroupedDiagnostics: + return self._grouped_diagnostics From 2e0167f8180c2725489cbfd407bbfc3d3b164447 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Apr 2026 15:08:56 +0200 Subject: [PATCH 42/48] Refactor LSP tools for symbol declaration retrieval * Apply common functionality already used in JetBrainsFindDeclarationTool * Remove location-based tool (counteracts Serena's principle to eschew line numbers) * Apply renaming to use "find declaration" instead of "find defining symbol" --- scripts/demo_find_defining_symbol.py | 22 +-- src/serena/code_editor.py | 10 +- src/serena/symbol.py | 10 +- src/serena/tools/symbol_tools.py | 272 ++++++--------------------- src/solidlsp/ls_utils.py | 8 + test/serena/test_serena_agent.py | 43 +---- 6 files changed, 91 insertions(+), 274 deletions(-) diff --git a/scripts/demo_find_defining_symbol.py b/scripts/demo_find_defining_symbol.py index f65b820c..63bde56c 100644 --- a/scripts/demo_find_defining_symbol.py +++ b/scripts/demo_find_defining_symbol.py @@ -11,7 +11,7 @@ from serena.agent import SerenaAgent from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig from serena.constants import REPO_ROOT from serena.project import Project -from serena.tools import FindDefiningSymbolAtLocationTool, FindDefiningSymbolTool +from serena.tools import FindDeclarationTool from solidlsp.ls_config import Language SEPARATOR = "=" * 80 @@ -73,24 +73,8 @@ if __name__ == "__main__": relative_path = SERVICES_FILE.as_posix() services_abs_path = PYTHON_TEST_REPO / SERVICES_FILE - # resolving via exact location - line, column = find_identifier_occurrence_position(services_abs_path, "User", occurrence_index=1) - find_by_location_tool = agent.get_tool(FindDefiningSymbolAtLocationTool) - location_result = agent.execute_task( - lambda: find_by_location_tool.apply( - relative_path=relative_path, - line=line, - column=column, - include_info=True, - ) - ) - - print_section("FindDefiningSymbolAtLocationTool") - location_symbol = json.loads(location_result) - pprint(location_symbol, width=200) - # resolving via regex over the full file - find_by_regex_tool = agent.get_tool(FindDefiningSymbolTool) + find_by_regex_tool = agent.get_tool(FindDeclarationTool) regex_result = agent.execute_task( lambda: find_by_regex_tool.apply( regex=r"from \.models import Item, (User)", @@ -118,7 +102,7 @@ if __name__ == "__main__": pprint(contained_regex_symbol, width=200) # validating the demonstrated result - for symbol in [location_symbol, regex_symbol, contained_regex_symbol]: + for symbol in [regex_symbol, contained_regex_symbol]: assert symbol is not None, "Expected a defining symbol result" assert symbol.get("relative_path") is not None assert "models.py" in symbol["relative_path"], symbol diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 2392d2ba..fed0297f 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -57,15 +57,21 @@ class CodeEditor(Generic[TSymbol], ABC): """ raise NotImplementedError("This method must be overridden for each subclass") - def read_file(self, relative_path: str) -> str: + def read_file(self, relative_path: str, lines: tuple[int, int] | None = None) -> str: """ Reads the content of a file. :param relative_path: the relative path of the file to read + :param lines: tuple of (first_line, last_line) to read only a specific line range (0-based, inclusive) :return: the content of the file """ with self._open_file_context(relative_path) as file: - return file.get_contents() + contents = file.get_contents() + if lines is None: + return contents + else: + first_line, last_line = lines + return TextUtils.get_text_in_lines_range(contents, first_line, last_line) @contextmanager def edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: diff --git a/src/serena/symbol.py b/src/serena/symbol.py index b44339e1..8bae7d14 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -310,6 +310,12 @@ class LanguageServerSymbol(Symbol, ToStringMixin): end_line = end_pos["line"] if end_pos else None return start_line, end_line + def get_body_line_numbers_or_raise(self) -> tuple[int, int]: + start_line, end_line = self.get_body_line_numbers() + if start_line is None or end_line is None: + raise ValueError(f"Body line numbers could not be determined for {self.get_name_path()}") + return start_line, end_line + @property def line(self) -> int | None: """ @@ -936,7 +942,7 @@ class LanguageServerSymbolRetriever: return [LanguageServerSymbol(s) for s in implementing_symbols] - def find_defining_symbol( + def find_declaration( self, relative_file_path: str, line: int, @@ -944,7 +950,7 @@ class LanguageServerSymbolRetriever: include_body: bool = False, ) -> LanguageServerSymbol | None: """ - Find the symbol that defines the symbol at the given file position. + Find the declaration/definition of the symbol at the given file position. :param relative_file_path: the relative path to the file in which the symbol usage occurs. :param line: the 0-based line number of the symbol usage. diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index d2626ae0..e146c5ea 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -4,7 +4,6 @@ Language server-related tools import copy import os -import re from collections import Counter, defaultdict from collections.abc import Sequence from typing import Any @@ -19,37 +18,10 @@ from serena.tools import ( ) from serena.tools.tools_base import ToolMarkerOptional from serena.util.ls_diagnostics import GroupedDiagnostics +from serena.util.text_utils import find_text_coordinates from solidlsp.ls_types import SymbolKind -def _offset_to_line_and_column(text: str, offset: int) -> tuple[int, int]: - if offset < 0 or offset > len(text): - raise ValueError(f"Offset out of range: {offset}") - prefix = text[:offset] - line = prefix.count("\n") - previous_newline_offset = prefix.rfind("\n") - column = offset if previous_newline_offset == -1 else offset - previous_newline_offset - 1 - return line, column - - -def _line_and_column_to_offset(text: str, line: int, column: int) -> int: - if line < 0 or column < 0: - raise ValueError(f"Line and column must be non-negative, got {line=}, {column=}") - - line_start_offsets = [0] - for match in re.finditer("\n", text): - line_start_offsets.append(match.end()) - - if line >= len(line_start_offsets): - raise ValueError(f"Line out of range: {line}") - - line_start_offset = line_start_offsets[line] - line_end_offset = line_start_offsets[line + 1] - 1 if line + 1 < len(line_start_offsets) else len(text) - if column > line_end_offset - line_start_offset: - raise ValueError(f"Column out of range for line {line}: {column}") - return line_start_offset + column - - class RestartLanguageServerTool(Tool, ToolMarkerOptional): """Restarts the language server, may be necessary when edits not through Serena happen.""" @@ -411,215 +383,85 @@ class FindImplementationsTool(Tool, ToolMarkerSymbolicRead): return self._limit_length(result, max_answer_chars) -class FindDefiningSymbolAtLocationTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): +class FindDeclarationTool(Tool, ToolMarkerSymbolicRead): """ - Finds the symbol that defines the symbol at the given file position using the language server backend. + Finds the declaration/definition of a symbol """ - @staticmethod - def _defining_symbol_to_result_dict( - symbol_retriever: Any, - defining_symbol: LanguageServerSymbol | None, - include_body: bool, - include_info: bool, - ) -> dict[str, Any] | None: - if defining_symbol is None: - return None - - symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) - if not include_body and include_info: - if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol): - symbol_dict["info"] = symbol_info - symbol_dict.pop("name", None) - return symbol_dict - - # noinspection PyDefaultArgument def apply( self, relative_path: str, - line: int, - column: int, + regex: str, + containing_symbol_name_path: str | None = None, include_body: bool = False, include_info: bool = False, - max_answer_chars: int = -1, - ) -> str: - """ - Finds the defining symbol for the symbol at the given position. - - :param relative_path: the relative path to the file containing the symbol usage. - :param line: the 0-based line number of the symbol usage. - :param column: the 0-based column number of the symbol usage. - :param include_body: whether to include the source code of the defining symbol. - :param include_info: whether to include additional info (hover-like, typically including docstring and signature) - about the defining symbol. Ignored if no defining symbol is found. - :param max_answer_chars: same as in the `find_symbol` tool. - :return: a JSON object representing the defining symbol, or `null` if no definition was found. - """ - symbol_retriever = self.create_language_server_symbol_retriever() - defining_symbol = symbol_retriever.find_defining_symbol( - relative_file_path=relative_path, - line=line, - column=column, - include_body=include_body, - ) - - if defining_symbol is None: - result = self._to_json(None) - return self._limit_length(result, max_answer_chars) - - symbol_dict = self._defining_symbol_to_result_dict(symbol_retriever, defining_symbol, include_body, include_info) - assert symbol_dict is not None - result = self._to_json(symbol_dict) - return self._limit_length(result, max_answer_chars) - - -class FindDefiningSymbolTool(Tool, ToolMarkerSymbolicRead): - """ - Finds the symbol that defines a uniquely captured regex match in a file or containing symbol body. - """ - - @staticmethod - def _describe_search_scope(relative_path: str, containing_symbol_name_path: str | None) -> str: - if containing_symbol_name_path: - return f"symbol body '{containing_symbol_name_path}' in file '{relative_path}'" - return f"file '{relative_path}'" - - @classmethod - def _format_match_preview(cls, match: re.Match[str]) -> str: - matched_text = match.group(0).replace("\n", "\\n") - return matched_text[:120] - - @classmethod - def _get_unique_captured_span(cls, match: re.Match[str], regex: str, search_scope_description: str) -> tuple[int, int] | str: - if match.re.groups == 0: - return ( - f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in " - f"{search_scope_description}." - ) - - matched_capture_spans = [span for span in match.regs[1:] if span != (-1, -1)] - if len(matched_capture_spans) != 1: - return ( - f"Error: Regex '{regex}' must produce exactly one matched capture in {search_scope_description}, " - f"but produced {len(matched_capture_spans)} for match '{cls._format_match_preview(match)}'." - ) - - capture_start_offset, capture_end_offset = matched_capture_spans[0] - if capture_start_offset == capture_end_offset: - return ( - f"Error: Regex '{regex}' produced an empty capture in {search_scope_description}; " - "the capture must select the referenced symbol text." - ) - return capture_start_offset, capture_end_offset - - def _find_unique_captured_location( - self, - relative_path: str, - regex: str, - containing_symbol_name_path: str | None, - ) -> tuple[int, int] | str: - # retrieving the search region - file_content = self.project.read_file(relative_path) - symbol_retriever = self.create_language_server_symbol_retriever() - search_scope_description = self._describe_search_scope(relative_path, containing_symbol_name_path) - search_start_offset = 0 - search_text = file_content - - if containing_symbol_name_path: - try: - containing_symbol = symbol_retriever.find_unique(containing_symbol_name_path, within_relative_path=relative_path) - except Exception as e: - return f"Error: Could not resolve containing symbol '{containing_symbol_name_path}' in file '{relative_path}': {e}" - - body_start_position = containing_symbol.get_body_start_position_or_raise() - body_end_position = containing_symbol.get_body_end_position_or_raise() - search_start_offset = _line_and_column_to_offset(file_content, body_start_position.line, body_start_position.col) - search_end_offset = _line_and_column_to_offset(file_content, body_end_position.line, body_end_position.col) - search_text = file_content[search_start_offset:search_end_offset] - - # finding regex matches - try: - compiled_regex = re.compile(regex, re.MULTILINE) - except re.error as e: - return f"Error: Invalid regex '{regex}': {e}" - - if compiled_regex.groups == 0: - return ( - f"Error: Regex '{regex}' must contain exactly one capturing group that identifies the symbol usage in " - f"{search_scope_description}." - ) - - matches = list(compiled_regex.finditer(search_text)) - if len(matches) != 1: - match_previews = [self._format_match_preview(match) for match in matches[:3]] - preview_suffix = f" Matches: {match_previews}" if match_previews else "" - return ( - f"Error: Expected exactly one regex match for '{regex}' in {search_scope_description}, " - f"but found {len(matches)}.{preview_suffix}" - ) - - capture_span_or_error = self._get_unique_captured_span(matches[0], regex, search_scope_description) - if isinstance(capture_span_or_error, str): - return capture_span_or_error - - capture_start_offset, _ = capture_span_or_error - absolute_capture_start_offset = search_start_offset + capture_start_offset - return _offset_to_line_and_column(file_content, absolute_capture_start_offset) - - # noinspection PyDefaultArgument - def apply( - self, - regex: str, - relative_path: str, - containing_symbol_name_path: str = "", - include_body: bool = False, - include_info: bool = False, - max_answer_chars: int = -1, ) -> str: r""" - Finds the defining symbol for a uniquely captured regex match in a file. + Finds the declaration of a symbol. - The regex must contain exactly one capturing group, and exactly one overall match must be found. - The capture identifies the symbol usage whose definition should be resolved. - - The regex is compiled with ``re.MULTILINE`` enabled and ``re.DOTALL`` disabled. This keeps common - single-symbol matches predictable. If cross-line matching is needed, opt in explicitly with ``(?s)`` - or ``[\\s\\S]*?``. - - :param regex: a Python regular expression containing exactly one capturing group. - :param relative_path: the relative path to the file containing the symbol usage. + :param relative_path: the relative path to the source file containing the symbol for which to find the declaration. + :param regex: a regular expression with one group, where the group matches the symbol for which to perform the lookup. + For example, to find the declaration of the `process` method in a call like `obj.process()`, + pass an expression like "obj\.(process)\(process_input_arg=37\)". + Prefer regexes with sufficiently large context around the group to render the match unambiguous. + Uses Python syntax with MULTILINE and DOTALL flags enabled. :param containing_symbol_name_path: optional name path of a containing symbol whose body shall be searched instead of the full file. - :param include_body: whether to include the source code of the defining symbol. - :param include_info: whether to include additional info (hover-like, typically including docstring and signature) - about the defining symbol. Ignored if no defining symbol is found. - :param max_answer_chars: same as in the `find_symbol` tool. - :return: a JSON object representing the defining symbol, or ``null`` if no definition was found. - If the regex does not identify a unique captured match, an informative error string is returned. + :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. """ - captured_location_or_error = self._find_unique_captured_location( - relative_path=relative_path, - regex=regex, - containing_symbol_name_path=containing_symbol_name_path or None, - ) - if isinstance(captured_location_or_error, str): - return captured_location_or_error - - line, column = captured_location_or_error symbol_retriever = self.create_language_server_symbol_retriever() - defining_symbol = symbol_retriever.find_defining_symbol( + relative_path = self._sanitize_input_param(relative_path) + regex = self._sanitize_input_param(regex) + + # find relevant location for lookup + editor = self.create_code_editor() + if not containing_symbol_name_path: + content = editor.read_file(relative_path) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + else: + symbol = symbol_retriever.find_unique(name_path_pattern=containing_symbol_name_path, within_relative_path=relative_path) + body_line_numers = symbol.get_body_line_numbers_or_raise() + content = editor.read_file(relative_path, lines=body_line_numers) + coords = find_text_coordinates(content, regex, require_unique=True) + assert coords is not None + coords.line += body_line_numers[0] + + # retrieve declaration + defining_symbol = symbol_retriever.find_declaration( relative_file_path=relative_path, - line=line, - column=column, + line=coords.line, + column=coords.col, include_body=include_body, ) - symbol_dict = FindDefiningSymbolAtLocationTool._defining_symbol_to_result_dict( + if defining_symbol is None: + raise ValueError( + f"No symbol declaration found at the location of the regex match. Location: {relative_path}:{coords.line}:{coords.col}." + ) + + # create output + symbol_dict = self._defining_symbol_to_result_dict( symbol_retriever, defining_symbol, include_body, include_info, ) result = self._to_json(symbol_dict) - return self._limit_length(result, max_answer_chars) + return result + + @staticmethod + def _defining_symbol_to_result_dict( + symbol_retriever: Any, + defining_symbol: LanguageServerSymbol, + include_body: bool, + include_info: bool, + ) -> dict[str, Any]: + symbol_dict = dict(defining_symbol.to_dict(kind=True, relative_path=True, depth=0, body=include_body, body_location=True)) + if not include_body and include_info: + if symbol_info := symbol_retriever.request_info_for_symbol(defining_symbol): + symbol_dict["info"] = symbol_info + symbol_dict.pop("name", None) + return symbol_dict class GetDiagnosticsForFileTool(Tool, ToolMarkerSymbolicRead): diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index b54654b8..73066e12 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -124,6 +124,14 @@ 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: + """ + Returns the text encompassed by the given start and end lines (inclusive). + """ + lines = text.splitlines(keepends=True) + return "".join(lines[start_line : end_line + 1]) + class PathUtils: """ diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index b3e2427b..4a30550a 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -19,8 +19,7 @@ from serena.tools import ( SUCCESS_RESULT, ActivateProjectTool, EditingToolWithDiagnostics, - FindDefiningSymbolAtLocationTool, - FindDefiningSymbolTool, + FindDeclarationTool, FindImplementationsTool, FindReferencingSymbolsTool, FindSymbolTool, @@ -958,38 +957,10 @@ class TestSerenaAgent: f"Expected to find reference to {case.symbol_name} in {case.reference_file}. refs={refs}" ) - @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_CASES, indirect=["serena_agent"]) - def test_find_defining_symbol(self, serena_agent: SerenaAgent, case: FindDefiningSymbolCase) -> None: - project_root = get_repo_path(case.language) - position = find_identifier_pos( - project_root / case.relative_path, - case.identifier, - case.occurrence_index, - case.column_offset, - ) - assert position is not None, f"Could not find occurrence {case.occurrence_index} of {case.identifier!r} in {case.relative_path}" - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolAtLocationTool) - result = find_defining_symbol_tool.apply( - relative_path=case.relative_path, - line=position[0], - column=position[1], - include_info=True, - ) - defining_symbol = json.loads(result) - assert defining_symbol is not None, f"Expected defining symbol for {case.identifier!r} in {case.relative_path}" - assert defining_symbol.get("relative_path") is not None - assert case.expected_definition_file in defining_symbol["relative_path"], ( - f"Expected defining symbol in {case.expected_definition_file!r}, got: {defining_symbol}" - ) - assert self._symbol_matches_expected_name(defining_symbol, case.expected_name), ( - f"Expected defining symbol name {case.expected_name!r}, got: {defining_symbol}" - ) - self._assert_symbol_info_present(serena_agent, defining_symbol) - @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_CASES, indirect=["serena_agent"]) - def test_find_defining_symbol_by_regex(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None: - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) - result = find_defining_symbol_tool.apply( + def test_find_declaration(self, serena_agent: SerenaAgent, case: RegexDefiningSymbolCase) -> None: + tool = serena_agent.get_tool(FindDeclarationTool) + result = tool.apply( regex=case.regex, relative_path=case.relative_path, containing_symbol_name_path=case.containing_symbol_name_path, @@ -1007,13 +978,13 @@ class TestSerenaAgent: self._assert_symbol_info_present(serena_agent, defining_symbol) @pytest.mark.parametrize("serena_agent,case", FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES, indirect=["serena_agent"]) - def test_find_defining_symbol_by_regex_error( + def test_find_declaration_error( self, serena_agent: SerenaAgent, case: RegexDefiningSymbolErrorCase, ) -> None: - find_defining_symbol_tool = serena_agent.get_tool(FindDefiningSymbolTool) - result = find_defining_symbol_tool.apply( + tool = serena_agent.get_tool(FindDeclarationTool) + result = tool.apply( regex=case.regex, relative_path=case.relative_path, containing_symbol_name_path=case.containing_symbol_name_path, From 4f0c3f83e3f6262a9b9828d6171e5e69b0bc3edd Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Apr 2026 15:22:37 +0200 Subject: [PATCH 43/48] Update change log and README to consider new diagnostics/declaration/implementations tools --- CHANGELOG.md | 13 ++++++++++++- README.md | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96330eaa..e65d8ceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,21 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* JetBrains: + - Add new tools: + - `jet_brains_list_inspections`: Lists available IDE inspections (akin to diagnostics), optionally filtered by language or group + - `jet_brains_run_inspections`: Runs IDE inspections on a file and returns the results + +* LSP Tools: + - Add new tools: + - `find_declaration`: Finds the declaration/definition of a symbol + - `find_implementations`: Finds the implementations of an interface or abstract method + - `get_diagnostics_for_file`: Retrieves diagnostics for a specific file (errors, warnings, etc.) + - `get_diagnostics_for_symbol`: Retrieves diagnostics pertaining to a specific symbol + * Language Servers: - Java (`eclipse.jdt.ls`): Add upstream JDTLS mode for offline / restricted-network use. Setting both `jdtls_path` and `lombok_path` in `ls_specific_settings.java` makes Serena use an existing upstream JDTLS installation (e.g. `brew install jdtls`) and the system JDK 21+, skipping the ~500 MB vscode-java VSIX, Gradle, and IntelliCode downloads. New related setting `java_home` lets the user override the JDK used to launch JDTLS. Default behavior unchanged — the JDTLS workspace hash is preserved bit-for-bit for users on the default route, so existing project caches are reused without a one-time reindex; the launcher path is mixed into the hash only when `jdtls_path` is set, isolating upstream installations from the default workspace. #1415 - # v1.2.0 (2026-04-27) * General: diff --git a/README.md b/README.md index 0c819fb4..a89d9267 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,10 @@ without reading entire files. | find referencing symbols | yes | yes | | search in project dependencies | -- | yes | | type hierarchy | -- | yes | -| find declaration | -- | yes | -| find implementations | -- | yes | +| find declaration | yes | yes | +| find implementations | yes | yes | | query external projects | yes | yes | +| diagnostics/inspections | yes | yes | ### Refactoring From 30ee9235faf1fd4ad333a3005e09b22f030db3b5 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Apr 2026 21:10:53 +0200 Subject: [PATCH 44/48] Update TestSerenaAgent.test_find_declaration_error --- test/serena/test_serena_agent.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 4a30550a..3e1829f3 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -606,15 +606,15 @@ FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [ relative_path=os.path.join("test_repo", "services.py"), regex=r"(User)", containing_symbol_name_path="", - error_fragment="Expected exactly one regex match", + error_fragment="Match must be unique", ).to_pytest_param(), RegexDefiningSymbolErrorCase( language=Language.PYTHON, id="python_regex_missing_group", relative_path=os.path.join("test_repo", "services.py"), - regex=r"User", - containing_symbol_name_path="UserService/create_user", - error_fragment="must contain exactly one capturing group", + regex=r"self.users.get\(id\)", + containing_symbol_name_path="UserService/get_user", + error_fragment="Regex must contain exactly one group", ).to_pytest_param(), ] @@ -984,13 +984,12 @@ class TestSerenaAgent: case: RegexDefiningSymbolErrorCase, ) -> None: tool = serena_agent.get_tool(FindDeclarationTool) - result = tool.apply( - regex=case.regex, - relative_path=case.relative_path, - containing_symbol_name_path=case.containing_symbol_name_path, - ) - assert result.startswith("Error: "), result - assert case.error_fragment in result, result + with pytest.raises(ValueError, match=case.error_fragment): + tool.apply( + regex=case.regex, + relative_path=case.relative_path, + containing_symbol_name_path=case.containing_symbol_name_path, + ) @pytest.mark.parametrize("serena_agent,diagnostic_case", DIAGNOSTIC_CASES, indirect=["serena_agent"]) def test_get_diagnostics_for_file(self, serena_agent: SerenaAgent, diagnostic_case: DiagnosticCase) -> None: From 7aeb7bc48224a4d6b46e0fa078b86a950fe35713 Mon Sep 17 00:00:00 2001 From: kurushimee Date: Fri, 1 May 2026 20:51:51 +0700 Subject: [PATCH 45/48] fix(lsp): omit optional Content-Type header from outgoing messages Per the LSP spec, Content-Type is optional and defaults to application/vscode-jsonrpc; charset=utf-8 (the exact value we were sending). Spec-compliant servers behave identically with or without it. In practice some servers have stricter parsers. Notably, Godot's GDScript LSP (LSPeer::handle_data in modules/gdscript/language_server/gdscript_language_protocol.cpp) extracts the body length via header.substr(16).to_int() and silently drops messages that contain any header beyond Content-Length. Sending only Content-Length is interoperable across the spec-compliant servers we use today and unblocks Godot's LSP. Also tightens the type signature of create_message from tuple[bytes, bytes, bytes] to tuple[bytes, bytes] now that there is no Content-Type element. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/solidlsp/lsp_protocol_handler/server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/solidlsp/lsp_protocol_handler/server.py b/src/solidlsp/lsp_protocol_handler/server.py index 36e24b15..da94e1eb 100644 --- a/src/solidlsp/lsp_protocol_handler/server.py +++ b/src/solidlsp/lsp_protocol_handler/server.py @@ -131,11 +131,14 @@ class StopLoopException(Exception): pass -def create_message(payload: PayloadLike) -> tuple[bytes, bytes, bytes]: +def create_message(payload: PayloadLike) -> tuple[bytes, bytes]: body = json.dumps(payload, check_circular=False, ensure_ascii=False, separators=(",", ":")).encode(ENCODING) + # NOTE: only Content-Length is sent. Content-Type is optional per the LSP + # spec (default: application/vscode-jsonrpc; charset=utf-8), and Godot's + # GDScript LSP parser only handles Content-Length — any additional header + # makes it silently drop the message. return ( - f"Content-Length: {len(body)}\r\n".encode(ENCODING), - "Content-Type: application/vscode-jsonrpc; charset=utf-8\r\n\r\n".encode(ENCODING), + f"Content-Length: {len(body)}\r\n\r\n".encode(ENCODING), body, ) From 90c4e78e545efccc2ea9f1bfcae4fc51205095f8 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Wed, 29 Apr 2026 20:18:20 +0200 Subject: [PATCH 46/48] LS: fix unversioned runtime dependency paths, keeping backwards compatibility --- .../language_servers/al_language_server.py | 51 ++++---- .../ansible_language_server.py | 19 ++- .../language_servers/bash_language_server.py | 24 +++- src/solidlsp/language_servers/clojure_lsp.py | 56 +++++++-- .../language_servers/dart_language_server.py | 47 ++++++-- .../language_servers/eclipse_jdtls.py | 111 ++++++++++++++---- .../language_servers/elm_language_server.py | 20 +++- .../fsharp_language_server.py | 13 +- .../groovy_language_server.py | 39 ++++-- .../language_servers/haxe_language_server.py | 32 +++-- .../language_servers/hlsl_language_server.py | 39 ++++-- src/solidlsp/language_servers/intelephense.py | 13 +- .../language_servers/json_language_server.py | 16 ++- .../kotlin_language_server.py | 41 +++++-- src/solidlsp/language_servers/lua_ls.py | 41 ++++++- src/solidlsp/language_servers/luau_lsp.py | 36 +++++- src/solidlsp/language_servers/marksman.py | 50 ++++++-- .../matlab_language_server.py | 47 +++++--- src/solidlsp/language_servers/omnisharp.py | 18 ++- .../language_servers/pascal_server.py | 46 ++++++-- src/solidlsp/language_servers/phpactor.py | 32 +++-- .../powershell_language_server.py | 38 ++++-- .../solidity_language_server.py | 30 ++++- src/solidlsp/language_servers/taplo_server.py | 48 +++++--- src/solidlsp/language_servers/terraform_ls.py | 50 ++++++-- .../typescript_language_server.py | 49 ++++---- .../language_servers/vts_language_server.py | 12 +- .../language_servers/yaml_language_server.py | 17 ++- 28 files changed, 789 insertions(+), 246 deletions(-) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index ed3cf9c7..438b1d3d 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -28,11 +28,28 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -AL_EXTENSION_VERSION = "18.0.2242655" -AL_EXTENSION_URL = ( - f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/vsextensions/al/{AL_EXTENSION_VERSION}/vspackage" -) -AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9" +# 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_AL_EXTENSION_VERSION = "18.0.2242655" +INITIAL_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9" +DEFAULT_AL_EXTENSION_VERSION = "18.0.2242655" +DEFAULT_AL_EXTENSION_SHA256 = "3971995e61a59dc4fcce4a65053072a67991ed624a16635c4f2911f12564b2b9" + + +def _al_extension_sha(version: str) -> str | None: + if version == INITIAL_AL_EXTENSION_VERSION: + return INITIAL_AL_EXTENSION_SHA256 + if version == DEFAULT_AL_EXTENSION_VERSION: + return DEFAULT_AL_EXTENSION_SHA256 + return None + + +def _al_extension_dirname(version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + return "al-extension" if version == INITIAL_AL_EXTENSION_VERSION else f"al-extension-{version}" + + AL_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.com",) @@ -131,21 +148,13 @@ class ALLanguageServer(SolidLanguageServer): return path.replace("\\", "/") @classmethod - def _download_al_extension(cls, url: str, target_dir: str) -> bool: + def _download_al_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool: """ Download and extract the AL extension from VS Code marketplace. The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). This method downloads the VSIX file and extracts it to get the language server binaries. - Args: - logger: Logger for tracking download progress - url: VS Code marketplace URL for the AL extension - target_dir: Directory where the extension will be extracted - - Returns: - True if successful, False otherwise - Note: The download includes progress tracking and proper user-agent headers to ensure compatibility with the VS Code marketplace. @@ -158,7 +167,7 @@ class ALLanguageServer(SolidLanguageServer): url, target_dir, "zip", - expected_sha256=AL_EXTENSION_SHA256 if url == AL_EXTENSION_URL else None, + expected_sha256=expected_sha256, allowed_hosts=AL_EXTENSION_ALLOWED_HOSTS, ) log.info("AL extension extracted successfully") @@ -231,8 +240,10 @@ class ALLanguageServer(SolidLanguageServer): elif env_path: log.warning(f"AL_EXTENSION_PATH set but directory not found: {env_path}") - # Check default download location - default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension", "extension") + # Check the resolved-version download location (versioned for non-INITIAL, legacy "al-extension" for INITIAL) + al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL) + al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION) + default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version), "extension") if os.path.exists(default_path): log.debug(f"Found AL extension in default location: {default_path}") return default_path @@ -255,9 +266,9 @@ class ALLanguageServer(SolidLanguageServer): Path to installed extension or None if download failed """ - al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension") al_settings = solidlsp_settings.get_ls_specific_settings(Language.AL) - al_extension_version = al_settings.get("al_extension_version", AL_EXTENSION_VERSION) + al_extension_version = al_settings.get("al_extension_version", DEFAULT_AL_EXTENSION_VERSION) + al_extension_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), _al_extension_dirname(al_extension_version)) al_extension_url = ( "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/" f"vsextensions/al/{al_extension_version}/vspackage" @@ -265,7 +276,7 @@ class ALLanguageServer(SolidLanguageServer): log.info(f"Downloading AL extension from: {al_extension_url}") - if cls._download_al_extension(al_extension_url, al_extension_dir): + if cls._download_al_extension(al_extension_url, al_extension_dir, _al_extension_sha(al_extension_version)): extension_path = os.path.join(al_extension_dir, "extension") if os.path.exists(extension_path): log.info("AL extension downloaded and installed successfully") diff --git a/src/solidlsp/language_servers/ansible_language_server.py b/src/solidlsp/language_servers/ansible_language_server.py index 2b3da993..3fb1a3b6 100644 --- a/src/solidlsp/language_servers/ansible_language_server.py +++ b/src/solidlsp/language_servers/ansible_language_server.py @@ -20,6 +20,12 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# 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_ANSIBLE_LANGUAGE_SERVER_VERSION = "1.2.3" +DEFAULT_ANSIBLE_LANGUAGE_SERVER_VERSION = "1.2.3" + def _deep_merge(base: dict, override: dict) -> None: """Recursively merge *override* into *base*, modifying *base* in-place.""" @@ -162,7 +168,9 @@ class AnsibleLanguageServer(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - ansible_language_server_version = self._custom_settings.get("ansible_language_server_version", "1.2.3") + ansible_language_server_version = self._custom_settings.get( + "ansible_language_server_version", DEFAULT_ANSIBLE_LANGUAGE_SERVER_VERSION + ) npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -180,8 +188,13 @@ class AnsibleLanguageServer(SolidLanguageServer): ] ) - # install ansible-language-server if not already installed - ansible_ls_dir = os.path.join(self._ls_resources_dir, "ansible-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "ansible-lsp" + if ansible_language_server_version == INITIAL_ANSIBLE_LANGUAGE_SERVER_VERSION + else f"ansible-lsp-{ansible_language_server_version}" + ) + ansible_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) ansible_executable_path = os.path.join(ansible_ls_dir, "node_modules", ".bin", "ansible-language-server") # handle Windows executable extension diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index 9dfc9356..0338a5ef 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -28,6 +28,14 @@ log = logging.getLogger(__name__) # ShellCheck binary release info; download directly from upstream GitHub releases rather than # relying on the `shellcheck` npm wrapper (which lazily downloads on first invocation and # turned the bash LS install into a fragile, network-dependent step). +# 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_BASH_LANGUAGE_SERVER_VERSION = "5.6.0" +DEFAULT_BASH_LANGUAGE_SERVER_VERSION = "5.6.0" + +# ShellCheck binary path already encodes _SHELLCHECK_VERSION (see _shellcheck_binary_path), +# so version bumps trigger reinstall correctly without further intervention. _SHELLCHECK_VERSION = "0.10.0" _SHELLCHECK_RELEASE_BASE = f"https://github.com/koalaman/shellcheck/releases/download/v{_SHELLCHECK_VERSION}" _SHELLCHECK_ALLOWED_HOSTS = ( @@ -110,10 +118,10 @@ class BashLanguageServer(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - bash_language_server_version = self._custom_settings.get("bash_language_server_version", "5.6.0") + bash_language_server_version = self._custom_settings.get("bash_language_server_version", DEFAULT_BASH_LANGUAGE_SERVER_VERSION) npm_registry = self._custom_settings.get("npm_registry") - bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") + bash_ls_dir = self._resolve_bash_ls_dir(bash_language_server_version) managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") bash_executable_path = os.path.join(managed_bin_dir, "bash-language-server") if os.name == "nt": @@ -180,13 +188,23 @@ class BashLanguageServer(SolidLanguageServer): os.chmod(binary_path, current | 0o111) def create_launch_command_env(self) -> dict[str, str]: - bash_ls_dir = os.path.join(self._ls_resources_dir, "bash-lsp") + bash_language_server_version = self._custom_settings.get("bash_language_server_version", DEFAULT_BASH_LANGUAGE_SERVER_VERSION) + bash_ls_dir = self._resolve_bash_ls_dir(bash_language_server_version) managed_bin_dir = os.path.join(bash_ls_dir, "node_modules", ".bin") return { "PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", ""), "SHELLCHECK_PATH": _shellcheck_binary_path(bash_ls_dir), } + def _resolve_bash_ls_dir(self, bash_language_server_version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "bash-lsp" + if bash_language_server_version == INITIAL_BASH_LANGUAGE_SERVER_VERSION + else f"bash-lsp-{bash_language_server_version}" + ) + return os.path.join(self._ls_resources_dir, ls_dirname) + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "start"] diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index 4fdc3ce9..dc238256 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -21,7 +21,35 @@ from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) -CLOJURE_LSP_VERSION = "2026.02.20-16.08.58" +# 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_CLOJURE_LSP_VERSION = "2026.02.20-16.08.58" +INITIAL_CLOJURE_LSP_SHA256_BY_PLATFORM = { + "osx-arm64": "a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669", + "osx-x64": "5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2", + "linux-arm64": "f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1", + "linux-x64": "52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309", + "win-x64": "817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f", +} +DEFAULT_CLOJURE_LSP_VERSION = "2026.02.20-16.08.58" +DEFAULT_CLOJURE_LSP_SHA256_BY_PLATFORM = { + "osx-arm64": "a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669", + "osx-x64": "5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2", + "linux-arm64": "f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1", + "linux-x64": "52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309", + "win-x64": "817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f", +} + + +def _clojure_lsp_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_CLOJURE_LSP_VERSION: + return INITIAL_CLOJURE_LSP_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_CLOJURE_LSP_VERSION: + return DEFAULT_CLOJURE_LSP_SHA256_BY_PLATFORM[platform_key] + return None + + CLOJURE_LSP_ALLOWED_HOSTS = ( "github.com", "release-assets.githubusercontent.com", @@ -58,7 +86,6 @@ class ClojureLSP(SolidLanguageServer): by Serena (default: the bundled Serena version). """ - CLOJURE_LSP_VERSION = CLOJURE_LSP_VERSION CLOJURE_LSP_ALLOWED_HOSTS = CLOJURE_LSP_ALLOWED_HOSTS @override @@ -69,7 +96,6 @@ class ClojureLSP(SolidLanguageServer): @classmethod def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection: clojure_lsp_releases = f"https://github.com/clojure-lsp/clojure-lsp/releases/download/{version}" - default_version = version == cls.CLOJURE_LSP_VERSION return RuntimeDependencyCollection( [ RuntimeDependency( @@ -78,7 +104,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="osx-arm64", archive_type="zip", binary_name="clojure-lsp", - sha256="a14d4db074f665378214e2dc888472e186c228dfa065c777b0534bfda5571669" if default_version else None, + sha256=_clojure_lsp_sha(version, "osx-arm64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), RuntimeDependency( @@ -87,7 +113,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="osx-x64", archive_type="zip", binary_name="clojure-lsp", - sha256="5507434c27104ab816e096d3336d8191641de8a65b57d76afb585d07167a3cf2" if default_version else None, + sha256=_clojure_lsp_sha(version, "osx-x64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), RuntimeDependency( @@ -96,7 +122,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="linux-arm64", archive_type="zip", binary_name="clojure-lsp", - sha256="f8f09fa07dd4b6743b5c57270ccf1ee5cdbc5fca09dbca8b6a3b22705b5da4e1" if default_version else None, + sha256=_clojure_lsp_sha(version, "linux-arm64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), RuntimeDependency( @@ -105,7 +131,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="linux-x64", archive_type="zip", binary_name="clojure-lsp", - sha256="52e8bf4fd4cf171df0a3077c8bb5a3bf598d4c621e94b4876dab943a61267309" if default_version else None, + sha256=_clojure_lsp_sha(version, "linux-x64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), RuntimeDependency( @@ -114,7 +140,7 @@ class ClojureLSP(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="clojure-lsp.exe", - sha256="817b1271288817c954fb9e595278b1f25003827ce31f8785f253dc4ac911041f" if default_version else None, + sha256=_clojure_lsp_sha(version, "win-x64"), allowed_hosts=CLOJURE_LSP_ALLOWED_HOSTS, ), ] @@ -143,16 +169,22 @@ class ClojureLSP(SolidLanguageServer): def _get_or_install_core_dependency(self) -> str: """Setup runtime dependencies for clojure-lsp and return the path to the executable.""" verify_clojure_cli() - clojure_lsp_version = self._custom_settings.get("clojure_lsp_version", ClojureLSP.CLOJURE_LSP_VERSION) + clojure_lsp_version = self._custom_settings.get("clojure_lsp_version", DEFAULT_CLOJURE_LSP_VERSION) deps = ClojureLSP._runtime_dependencies(clojure_lsp_version) dependency = deps.get_single_dep_for_current_platform() - clojurelsp_executable_path = deps.binary_path(self._ls_resources_dir) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + self._ls_resources_dir + if clojure_lsp_version == INITIAL_CLOJURE_LSP_VERSION + else os.path.join(self._ls_resources_dir, f"clojure-lsp-{clojure_lsp_version}") + ) + clojurelsp_executable_path = deps.binary_path(install_dir) if not os.path.exists(clojurelsp_executable_path): log.info( - f"Downloading and extracting clojure-lsp from {dependency.url} to {self._ls_resources_dir}", + f"Downloading and extracting clojure-lsp from {dependency.url} to {install_dir}", ) - deps.install(self._ls_resources_dir) + deps.install(install_dir) if not os.path.exists(clojurelsp_executable_path): raise FileNotFoundError(f"Download failed? Could not find clojure-lsp executable at {clojurelsp_executable_path}") os.chmod(clojurelsp_executable_path, 0o755) diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index f82627c6..800f69eb 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -18,6 +18,34 @@ log = logging.getLogger(__name__) DART_ALLOWED_HOSTS = ("storage.googleapis.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_DART_SDK_VERSION = "3.7.1" +INITIAL_DART_SDK_SHA256_BY_PLATFORM = { + "linux-x64": "2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71", + "win-x64": "f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5", + "win-arm64": "fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a", + "osx-x64": "a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e", + "osx-arm64": "f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4", +} +DEFAULT_DART_SDK_VERSION = "3.7.1" +DEFAULT_DART_SDK_SHA256_BY_PLATFORM = { + "linux-x64": "2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71", + "win-x64": "f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5", + "win-arm64": "fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a", + "osx-x64": "a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e", + "osx-arm64": "f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4", +} + + +def _dart_sdk_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_DART_SDK_VERSION: + return INITIAL_DART_SDK_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_DART_SDK_VERSION: + return DEFAULT_DART_SDK_SHA256_BY_PLATFORM[platform_key] + return None + class DartLanguageServer(SolidLanguageServer): """ @@ -49,7 +77,7 @@ class DartLanguageServer(SolidLanguageServer): @classmethod def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: dart_settings = solidlsp_settings.get_ls_specific_settings(Language.DART) - dart_sdk_version = dart_settings.get("dart_sdk_version", "3.7.1") + dart_sdk_version = dart_settings.get("dart_sdk_version", DEFAULT_DART_SDK_VERSION) deps = RuntimeDependencyCollection( [ RuntimeDependency( @@ -59,7 +87,7 @@ class DartLanguageServer(SolidLanguageServer): platform_id="linux-x64", archive_type="zip", binary_name="dart-sdk/bin/dart", - sha256="2813959e7d9650334015b927cc533f5beadfbf7fa48248beec471f8942a0ee71" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "linux-x64"), allowed_hosts=DART_ALLOWED_HOSTS, ), RuntimeDependency( @@ -69,7 +97,7 @@ class DartLanguageServer(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="dart-sdk/bin/dart.exe", - sha256="f56c03122e17abe5be1429eee0a975fb8ed511b6731ec90c6475992d3dee4ea5" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "win-x64"), allowed_hosts=DART_ALLOWED_HOSTS, ), RuntimeDependency( @@ -79,7 +107,7 @@ class DartLanguageServer(SolidLanguageServer): platform_id="win-arm64", archive_type="zip", binary_name="dart-sdk/bin/dart.exe", - sha256="fada411c6538d0ac24c35d6360767241f1298f64cbc5e88716387d54757a105a" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "win-arm64"), allowed_hosts=DART_ALLOWED_HOSTS, ), RuntimeDependency( @@ -89,7 +117,7 @@ class DartLanguageServer(SolidLanguageServer): platform_id="osx-x64", archive_type="zip", binary_name="dart-sdk/bin/dart", - sha256="a2765917b6ae49d1ac119553df9584989f9c441a46e8f18c129ba52489658d2e" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "osx-x64"), allowed_hosts=DART_ALLOWED_HOSTS, ), RuntimeDependency( @@ -99,13 +127,18 @@ class DartLanguageServer(SolidLanguageServer): platform_id="osx-arm64", archive_type="zip", binary_name="dart-sdk/bin/dart", - sha256="f57c25163092bac818f8ca6250a0d8b2c56344c6a075a1bd7c60da7ac28b32a4" if dart_sdk_version == "3.7.1" else None, + sha256=_dart_sdk_sha(dart_sdk_version, "osx-arm64"), allowed_hosts=DART_ALLOWED_HOSTS, ), ] ) - dart_ls_dir = cls.ls_resources_dir(solidlsp_settings) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + dart_ls_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if dart_sdk_version == INITIAL_DART_SDK_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"dart-sdk-{dart_sdk_version}") + ) dart_executable_path = deps.binary_path(dart_ls_dir) if not os.path.exists(dart_executable_path): diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 50deb1b0..646174a5 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -32,19 +32,57 @@ log = logging.getLogger(__name__) GRADLE_ALLOWED_HOSTS = ("services.gradle.org", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") GRADLE_SHA256 = "7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999" VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -VSCODE_JAVA_SHA256_BY_PLATFORM = { +INTELLICODE_ALLOWED_HOSTS = ( + "visualstudioexptteam.gallery.vsassets.io", + "marketplace.visualstudio.com", + "download.visualstudio.microsoft.com", +) + +# Version pinning convention (read this before bumping anything below): +# +# INITIAL_* — the very first version we shipped runtime-dependency support for, paired with +# its SHA. NEVER edited. The legacy unversioned install directory (e.g. "vscode-java/") +# is reserved exclusively for this version, so users who installed under the original +# layout keep their existing cache forever. +# +# DEFAULT_* — the version (and matching SHA) used when the user does not override via +# custom_settings. THIS is what we edit to upgrade: copy the new version + new SHA over +# the existing DEFAULT_* literals, leave INITIAL_* alone. +# +# Resolution at install time: +# resolved_version = custom_settings.get("..._version", DEFAULT_*_VERSION) +# - resolved == INITIAL_* -> legacy unversioned dir, SHA = INITIAL_*_SHA256 +# - resolved == DEFAULT_* -> versioned dir "{name}-{resolved}", SHA = DEFAULT_*_SHA256 +# - any other version -> versioned dir "{name}-{resolved}", SHA verification skipped +# +# Consequences of this scheme: +# - Bumping DEFAULT_* makes new installs land in a new versioned subdir => no silent reuse +# of stale binaries (the bug this scheme exists to fix). +# - Previously-downloaded versions are not lost: a user can request any past version via +# custom_settings and the matching cached subdir is picked up without re-downloading. +# - INITIAL_* and DEFAULT_* hold identical literals when first introduced; they diverge on +# the first DEFAULT_* bump and stay independent thereafter. +INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561" +INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = { "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", } -INTELLICODE_ALLOWED_HOSTS = ( - "visualstudioexptteam.gallery.vsassets.io", - "marketplace.visualstudio.com", - "download.visualstudio.microsoft.com", -) -INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" +DEFAULT_VSCODE_JAVA_VERSION = "1.42.0-561" +DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = { + "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", + "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", + "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", + "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", + "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", +} + +INITIAL_INTELLICODE_VERSION = "1.2.30" +INITIAL_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" +DEFAULT_INTELLICODE_VERSION = "1.2.30" +DEFAULT_INTELLICODE_SHA256 = "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251" # Mapping from Serena's platform identifiers to upstream JDTLS config_ directory names JDTLS_CONFIG_DIR_BY_PLATFORM = { @@ -237,12 +275,35 @@ class EclipseJDTLS(SolidLanguageServer): platformId = PlatformUtils.get_platform_id() gradle_version = custom_settings.get("gradle_version", "8.14.2") - vscode_java_version = custom_settings.get("vscode_java_version", "1.42.0-561") + vscode_java_version = custom_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}" - intellicode_version = custom_settings.get("intellicode_version", "1.2.30") + intellicode_version = custom_settings.get("intellicode_version", DEFAULT_INTELLICODE_VERSION) default_gradle_version = gradle_version == "8.14.2" - default_vscode_java_version = vscode_java_version == "1.42.0-561" - default_intellicode_version = intellicode_version == "1.2.30" + + # install-dir name per the version-pinning convention (see module-level block): + # INITIAL -> legacy unversioned dir; everything else -> "{name}-{resolved}" subdir + vscode_java_dirname = ( + "vscode-java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"vscode-java-{vscode_java_version}" + ) + intellicode_dirname = ( + "intellicode" if intellicode_version == INITIAL_INTELLICODE_VERSION else f"intellicode-{intellicode_version}" + ) + + # SHA is only known for our two pinned versions (INITIAL and current DEFAULT); + # for any other user-supplied version we skip verification (returns None) + def vscode_java_sha(platform_key: str) -> str | None: + if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION: + return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + if vscode_java_version == DEFAULT_VSCODE_JAVA_VERSION: + return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + return None + + def intellicode_sha() -> str | None: + if intellicode_version == INITIAL_INTELLICODE_VERSION: + return INITIAL_INTELLICODE_SHA256 + if intellicode_version == DEFAULT_INTELLICODE_VERSION: + return DEFAULT_INTELLICODE_SHA256 + return None runtime_dependencies: dict[str, dict[str, dict[str, object]]] = { "gradle": { @@ -258,15 +319,15 @@ class EclipseJDTLS(SolidLanguageServer): "darwin-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-arm64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, }, "osx-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-arm64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-macosx-aarch64", "jre_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", @@ -277,8 +338,8 @@ class EclipseJDTLS(SolidLanguageServer): "osx-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-x64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("osx-x64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-macosx-x86_64", "jre_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", @@ -289,8 +350,8 @@ class EclipseJDTLS(SolidLanguageServer): "linux-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-arm64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("linux-arm64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-linux-aarch64", "jre_path": "extension/jre/21.0.7-linux-aarch64/bin/java", @@ -301,8 +362,8 @@ class EclipseJDTLS(SolidLanguageServer): "linux-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-x64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("linux-x64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-linux-x86_64", "jre_path": "extension/jre/21.0.7-linux-x86_64/bin/java", @@ -313,8 +374,8 @@ class EclipseJDTLS(SolidLanguageServer): "win-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-win32-x64-{vscode_java_version}.vsix", "archiveType": "zip", - "relative_extraction_path": "vscode-java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["win-x64"] if default_vscode_java_version else None, + "relative_extraction_path": vscode_java_dirname, + "sha256": vscode_java_sha("win-x64"), "allowed_hosts": VSCODE_JAVA_ALLOWED_HOSTS, "jre_home_path": "extension/jre/21.0.7-win32-x86_64", "jre_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", @@ -328,8 +389,8 @@ class EclipseJDTLS(SolidLanguageServer): "url": f"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/{intellicode_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", "alternate_url": f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/VisualStudioExptTeam/vsextensions/vscodeintellicode/{intellicode_version}/vspackage", "archiveType": "zip", - "relative_extraction_path": "intellicode", - "sha256": INTELLICODE_SHA256 if default_intellicode_version else None, + "relative_extraction_path": intellicode_dirname, + "sha256": intellicode_sha(), "allowed_hosts": INTELLICODE_ALLOWED_HOSTS, "intellicode_jar_path": "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar", "intellisense_members_path": "extension/dist/bundledModels/java_intellisense-members", diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index a86a1d2d..e5823811 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -22,6 +22,14 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in log = logging.getLogger(__name__) +# 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_ELM_LANGUAGE_SERVER_VERSION = "2.8.0" +DEFAULT_ELM_LANGUAGE_SERVER_VERSION = "2.8.0" +INITIAL_ELM_COMPILER_VERSION = "0.19.1-6" +DEFAULT_ELM_COMPILER_VERSION = "0.19.1-6" + class ElmLanguageServer(SolidLanguageServer): """ @@ -68,8 +76,8 @@ class ElmLanguageServer(SolidLanguageServer): Setup runtime dependencies for Elm Language Server and return the command to start the server. """ elm_config = solidlsp_settings.get_ls_specific_settings(Language.ELM) - elm_language_server_version = elm_config.get("elm_language_server_version", "2.8.0") - elm_compiler_version = elm_config.get("elm_compiler_version", "0.19.1-6") + elm_language_server_version = elm_config.get("elm_language_server_version", DEFAULT_ELM_LANGUAGE_SERVER_VERSION) + elm_compiler_version = elm_config.get("elm_compiler_version", DEFAULT_ELM_COMPILER_VERSION) npm_registry = elm_config.get("npm_registry") # Check if elm-language-server and elm are already installed globally @@ -101,8 +109,12 @@ class ElmLanguageServer(SolidLanguageServer): ] ) - # Install elm-language-server if not already installed - elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "elm-lsp") + # legacy unversioned dir reserved for INITIAL pair; any other version combination goes into a versioned subdir + is_initial = ( + elm_language_server_version == INITIAL_ELM_LANGUAGE_SERVER_VERSION and elm_compiler_version == INITIAL_ELM_COMPILER_VERSION + ) + ls_dirname = "elm-lsp" if is_initial else f"elm-lsp-{elm_language_server_version}-{elm_compiler_version}" + elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) elm_ls_executable_path = os.path.join(elm_ls_dir, "node_modules", ".bin", "elm-language-server") if not os.path.exists(elm_ls_executable_path): log.info(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...") diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py index 79970bae..a8cd6847 100644 --- a/src/solidlsp/language_servers/fsharp_language_server.py +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -22,7 +22,12 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -FSAUTOCOMPLETE_VERSION = "0.83.0" +# 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_FSAUTOCOMPLETE_VERSION = "0.83.0" +DEFAULT_FSAUTOCOMPLETE_VERSION = "0.83.0" +FSAUTOCOMPLETE_VERSION = DEFAULT_FSAUTOCOMPLETE_VERSION class FSharpLanguageServer(SolidLanguageServer): @@ -69,7 +74,7 @@ class FSharpLanguageServer(SolidLanguageServer): Setup runtime dependencies for F# Language Server and return the command to start the server. """ fsharp_settings = solidlsp_settings.get_ls_specific_settings(Language.FSHARP) - fsautocomplete_version = fsharp_settings.get("fsautocomplete_version", FSAUTOCOMPLETE_VERSION) + fsautocomplete_version = fsharp_settings.get("fsautocomplete_version", DEFAULT_FSAUTOCOMPLETE_VERSION) dotnet_exe = DotNETUtil("8.0", allow_higher_version=True).get_dotnet_path_or_raise() RuntimeDependencyCollection( @@ -84,7 +89,9 @@ class FSharpLanguageServer(SolidLanguageServer): ) # Install FsAutoComplete if not already installed - fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "fsharp-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "fsharp-lsp" if fsautocomplete_version == INITIAL_FSAUTOCOMPLETE_VERSION else f"fsharp-lsp-{fsautocomplete_version}" + fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) fsautocomplete_path = os.path.join(fsharp_ls_dir, "fsautocomplete") # Handle Windows executable extension diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py index 66bcacc7..2370e128 100644 --- a/src/solidlsp/language_servers/groovy_language_server.py +++ b/src/solidlsp/language_servers/groovy_language_server.py @@ -18,13 +18,34 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -VSCODE_JAVA_SHA256_BY_PLATFORM = { + +# 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_VSCODE_JAVA_VERSION = "1.42.0-561" +INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM = { "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", } +DEFAULT_VSCODE_JAVA_VERSION = "1.42.0-561" +DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM = { + "win-x64": "ef195b45bd260976ad2e84618f4044b5d7248deed41d647573f0ee22c4233df3", + "linux-x64": "7660b7b527be6fda46a917966b34d828e7416d5cc84287b29b88e7b99c1737f9", + "linux-arm64": "e15bc9b2a665d3453203402621b5441062aa41b0ec2d140661f439326fd248c1", + "osx-x64": "03ae1db1a22c15561a620f1b722d6797d35d4faaa7c4666dbe6ca2715089852f", + "osx-arm64": "bc00c2699d4b8d478eb9a1621db9d6d3a12ea0dcc247a9cd8040e8ac19c03933", +} + + +def _vscode_java_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_VSCODE_JAVA_VERSION: + return INITIAL_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_VSCODE_JAVA_VERSION: + return DEFAULT_VSCODE_JAVA_SHA256_BY_PLATFORM[platform_key] + return None @dataclasses.dataclass @@ -93,7 +114,7 @@ class GroovyLanguageServer(SolidLanguageServer): """ platform_id = PlatformUtils.get_platform_id() groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) - vscode_java_version = groovy_settings.get("vscode_java_version", "1.42.0-561") + vscode_java_version = groovy_settings.get("vscode_java_version", DEFAULT_VSCODE_JAVA_VERSION) vscode_java_tag = f"v{vscode_java_version.rsplit('-', 1)[0]}" # Verify platform support @@ -128,35 +149,35 @@ class GroovyLanguageServer(SolidLanguageServer): "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-win32-x86_64", "java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["win-x64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "win-x64"), }, "linux-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-x64-{vscode_java_version}.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-linux-x86_64", "java_path": "extension/jre/21.0.7-linux-x86_64/bin/java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-x64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "linux-x64"), }, "linux-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-linux-arm64-{vscode_java_version}.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-linux-aarch64", "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["linux-arm64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "linux-arm64"), }, "osx-x64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-x64-{vscode_java_version}.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-macosx-x86_64", "java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-x64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "osx-x64"), }, "osx-arm64": { "url": f"https://github.com/redhat-developer/vscode-java/releases/download/{vscode_java_tag}/java-darwin-arm64-{vscode_java_version}.vsix", "archiveType": "zip", "java_home_path": "extension/jre/21.0.7-macosx-aarch64", "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", - "sha256": VSCODE_JAVA_SHA256_BY_PLATFORM["osx-arm64"] if vscode_java_version == "1.42.0-561" else None, + "sha256": _vscode_java_sha(vscode_java_version, "osx-arm64"), }, }, } @@ -174,7 +195,9 @@ class GroovyLanguageServer(SolidLanguageServer): static_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "groovy_language_server") os.makedirs(static_dir, exist_ok=True) - java_dir = os.path.join(static_dir, "java") + # legacy unversioned "java" dir reserved for INITIAL; every other version goes into a versioned subdir + java_dirname = "java" if vscode_java_version == INITIAL_VSCODE_JAVA_VERSION else f"java-{vscode_java_version}" + java_dir = os.path.join(static_dir, java_dirname) os.makedirs(java_dir, exist_ok=True) java_home_path = os.path.join(java_dir, java_home_relative_path) diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py index cf48c78a..24ca6fe7 100644 --- a/src/solidlsp/language_servers/haxe_language_server.py +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -27,6 +27,22 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# 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_VSHAXE_VERSION = "2.34.2" +INITIAL_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" +DEFAULT_VSHAXE_VERSION = "2.34.2" +DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" + + +def _vshaxe_sha(version: str) -> str | None: + if version == INITIAL_VSHAXE_VERSION: + return INITIAL_VSHAXE_SHA256 + if version == DEFAULT_VSHAXE_VERSION: + return DEFAULT_VSHAXE_SHA256 + return None + class HaxeLanguageServer(SolidLanguageServer): """Haxe language server integration using vshaxe/haxe-language-server. @@ -58,8 +74,6 @@ class HaxeLanguageServer(SolidLanguageServer): class DependencyProvider(LanguageServerDependencyProviderSinglePath): # Downloaded from Open VSX (not the VS Code Marketplace) because Open VSX # provides stable versioned URLs and SHA256 checksums for integrity verification. - _DEFAULT_VSHAXE_VERSION = "2.34.2" - _DEFAULT_VSHAXE_SHA256 = "104d785e3f7b57a7f3debf520d9751f7e7abf3a7e78d203db1a8ff3dc7ca30e2" @override def _get_or_install_core_dependency(self) -> str: @@ -77,7 +91,9 @@ class HaxeLanguageServer(SolidLanguageServer): return vscode_server_path # 3. Check resource dir / download from Open VSX - haxe_ls_dir = os.path.join(self._ls_resources_dir, "haxe-lsp") + version = self._custom_settings.get("version", DEFAULT_VSHAXE_VERSION) + ls_dirname = "haxe-lsp" if version == INITIAL_VSHAXE_VERSION else f"haxe-lsp-{version}" + haxe_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) server_js_path = os.path.join(haxe_ls_dir, "bin", "server.js") if os.path.exists(server_js_path): log.info(f"Found Haxe Language Server at {server_js_path}") @@ -92,7 +108,6 @@ class HaxeLanguageServer(SolidLanguageServer): " 3. Set ls_path in serena_config.yml under ls_specific_settings.haxe" ) - version = self._custom_settings.get("version", self._DEFAULT_VSHAXE_VERSION) downloaded_path = self._download_from_open_vsx(haxe_ls_dir, version) if downloaded_path: return downloaded_path @@ -129,16 +144,17 @@ class HaxeLanguageServer(SolidLanguageServer): vsix_path = os.path.join(tempfile.gettempdir(), "vshaxe.vsix") urllib.request.urlretrieve(download_url, vsix_path) - # Verify SHA256 checksum only for the default (pinned) version - if version == cls._DEFAULT_VSHAXE_VERSION: + # Verify SHA256 checksum only when the resolved version is one of our pinned ones (INITIAL or current DEFAULT) + expected_sha = _vshaxe_sha(version) + if expected_sha is not None: sha256 = hashlib.sha256() with open(vsix_path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256.update(chunk) - if sha256.hexdigest().lower() != cls._DEFAULT_VSHAXE_SHA256: + if sha256.hexdigest().lower() != expected_sha: os.remove(vsix_path) raise RuntimeError( - f"SHA256 checksum mismatch for vshaxe VSIX. Expected {cls._DEFAULT_VSHAXE_SHA256}, " + f"SHA256 checksum mismatch for vshaxe VSIX. Expected {expected_sha}, " f"got {sha256.hexdigest()}. The file may be corrupted or tampered with." ) log.info("SHA256 checksum verified") diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py index b6eb7e05..cab201dd 100644 --- a/src/solidlsp/language_servers/hlsl_language_server.py +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -25,15 +25,32 @@ from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) -# GitHub release version to download when not installed locally -_DEFAULT_VERSION = "1.3.1" _GITHUB_RELEASE_BASE = "https://github.com/antaalt/shader-sense/releases/download" _HLSL_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -_HLSL_SHA256_BY_ASSET = { + +# 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_VERSION = "1.3.1" +_INITIAL_HLSL_SHA256_BY_ASSET = { "shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997", "shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c", "shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412", } +_DEFAULT_VERSION = "1.3.1" +_DEFAULT_HLSL_SHA256_BY_ASSET = { + "shader-language-server-x86_64-pc-windows-msvc.zip": "49081c5547ddde1b8b3b17295282a80ddacbca1d6f5dcd834e2788c02bafa997", + "shader-language-server-x86_64-unknown-linux-gnu.zip": "61710df7ca17a2d063b598936c57c56c49fbf837707a1aa886f9b0193a35be3c", + "shader-language-server-aarch64-pc-windows-msvc.zip": "a3b3799affe2cad27652e788376b46fe76e1a6c2ce45946a486dcb26c9091412", +} + + +def _hlsl_sha(version: str, asset_name: str) -> str | None: + if version == _INITIAL_VERSION: + return _INITIAL_HLSL_SHA256_BY_ASSET.get(asset_name) + if version == _DEFAULT_VERSION: + return _DEFAULT_HLSL_SHA256_BY_ASSET.get(asset_name) + return None class HlslLanguageServer(SolidLanguageServer): @@ -78,9 +95,7 @@ class HlslLanguageServer(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="shader-language-server.exe", - sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-x86_64-pc-windows-msvc.zip"] - if version == _DEFAULT_VERSION - else None, + sha256=_hlsl_sha(version, "shader-language-server-x86_64-pc-windows-msvc.zip"), allowed_hosts=_HLSL_ALLOWED_HOSTS, ), RuntimeDependency( @@ -90,9 +105,7 @@ class HlslLanguageServer(SolidLanguageServer): platform_id="linux-x64", archive_type="zip", binary_name="shader-language-server", - sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-x86_64-unknown-linux-gnu.zip"] - if version == _DEFAULT_VERSION - else None, + sha256=_hlsl_sha(version, "shader-language-server-x86_64-unknown-linux-gnu.zip"), allowed_hosts=_HLSL_ALLOWED_HOSTS, ), RuntimeDependency( @@ -102,9 +115,7 @@ class HlslLanguageServer(SolidLanguageServer): platform_id="win-arm64", archive_type="zip", binary_name="shader-language-server.exe", - sha256=_HLSL_SHA256_BY_ASSET["shader-language-server-aarch64-pc-windows-msvc.zip"] - if version == _DEFAULT_VERSION - else None, + sha256=_hlsl_sha(version, "shader-language-server-aarch64-pc-windows-msvc.zip"), allowed_hosts=_HLSL_ALLOWED_HOSTS, ), RuntimeDependency( @@ -139,7 +150,9 @@ class HlslLanguageServer(SolidLanguageServer): "See https://github.com/antaalt/shader-sense for more details." ) - install_dir = os.path.join(self._ls_resources_dir, "shader-language-server") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "shader-language-server" if version == _INITIAL_VERSION else f"shader-language-server-{version}" + install_dir = os.path.join(self._ls_resources_dir, ls_dirname) executable_path = deps.binary_path(install_dir) if not os.path.exists(executable_path): diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index 0c77af6d..47212695 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -21,6 +21,12 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in log = logging.getLogger(__name__) +# 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_INTELEPHENSE_VERSION = "1.14.4" +DEFAULT_INTELEPHENSE_VERSION = "1.14.4" + class Intelephense(SolidLanguageServer): """ @@ -59,11 +65,12 @@ class Intelephense(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - intelephense_version = self._custom_settings.get("intelephense_version", "1.14.4") + intelephense_version = self._custom_settings.get("intelephense_version", DEFAULT_INTELEPHENSE_VERSION) npm_registry = self._custom_settings.get("npm_registry") - # Install intelephense if not already installed - intelephense_ls_dir = os.path.join(self._ls_resources_dir, "php-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "php-lsp" if intelephense_version == INITIAL_INTELEPHENSE_VERSION else f"php-lsp-{intelephense_version}" + intelephense_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) os.makedirs(intelephense_ls_dir, exist_ok=True) intelephense_executable_path = os.path.join(intelephense_ls_dir, "node_modules", ".bin", "intelephense") if not os.path.exists(intelephense_executable_path): diff --git a/src/solidlsp/language_servers/json_language_server.py b/src/solidlsp/language_servers/json_language_server.py index bf46992a..f764418e 100644 --- a/src/solidlsp/language_servers/json_language_server.py +++ b/src/solidlsp/language_servers/json_language_server.py @@ -17,6 +17,12 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# 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_JSON_LANGUAGE_SERVER_VERSION = "1.3.4" +DEFAULT_JSON_LANGUAGE_SERVER_VERSION = "1.3.4" + class JsonLanguageServer(SolidLanguageServer): """ @@ -54,7 +60,7 @@ class JsonLanguageServer(SolidLanguageServer): is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - json_language_server_version = self._custom_settings.get("json_language_server_version", "1.3.4") + json_language_server_version = self._custom_settings.get("json_language_server_version", DEFAULT_JSON_LANGUAGE_SERVER_VERSION) npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -68,7 +74,13 @@ class JsonLanguageServer(SolidLanguageServer): ] ) - json_ls_dir = os.path.join(self._ls_resources_dir, "json-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "json-lsp" + if json_language_server_version == INITIAL_JSON_LANGUAGE_SERVER_VERSION + else f"json-lsp-{json_language_server_version}" + ) + json_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) json_executable_path = os.path.join(json_ls_dir, "node_modules", ".bin", "vscode-json-languageserver") if os.name == "nt": diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py index bd2d3035..3998dafa 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -41,16 +41,36 @@ log = logging.getLogger(__name__) # -Xmx2G: 2GB heap is sufficient for most projects; override via ls_specific_settings for large codebases DEFAULT_KOTLIN_JVM_OPTIONS = "-Xmx2G" -# Default Kotlin Language Server version (can be overridden via ls_specific_settings) -DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0" KOTLIN_LSP_ALLOWED_HOSTS = ("download-cdn.jetbrains.com",) -KOTLIN_LSP_SHA256_BY_SUFFIX = { + +# 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_KOTLIN_LSP_VERSION = "261.13587.0" +INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX = { "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", } +DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0" +DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX = { + "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", + "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", + "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", + "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", + "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", +} + + +def _kotlin_lsp_sha(version: str, kotlin_suffix: str) -> str | None: + if version == INITIAL_KOTLIN_LSP_VERSION: + return INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) + if version == DEFAULT_KOTLIN_LSP_VERSION: + return DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) + return None + # Platform-specific Kotlin LSP download suffixes PLATFORM_KOTLIN_SUFFIX = { @@ -108,8 +128,14 @@ class KotlinLanguageServer(SolidLanguageServer): kotlin_suffix = PLATFORM_KOTLIN_SUFFIX.get(platform_id.value) assert kotlin_suffix, f"Unsupported platform for Kotlin LSP: {platform_id.value}" - # Setup paths for dependencies - static_dir = os.path.join(self._ls_resources_dir, "kotlin_language_server") + # Setup paths for dependencies; legacy unversioned dir reserved for INITIAL only + kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION) + ls_dirname = ( + "kotlin_language_server" + if kotlin_lsp_version == INITIAL_KOTLIN_LSP_VERSION + else f"kotlin_language_server-{kotlin_lsp_version}" + ) + static_dir = os.path.join(self._ls_resources_dir, ls_dirname) os.makedirs(static_dir, exist_ok=True) # Setup Kotlin Language Server @@ -117,11 +143,8 @@ class KotlinLanguageServer(SolidLanguageServer): kotlin_script = os.path.join(static_dir, kotlin_script_name) if not os.path.exists(kotlin_script): - kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION) kotlin_url = f"https://download-cdn.jetbrains.com/kotlin-lsp/{kotlin_lsp_version}/kotlin-lsp-{kotlin_lsp_version}-{kotlin_suffix}.zip" - expected_sha256 = None - if kotlin_lsp_version == DEFAULT_KOTLIN_LSP_VERSION: - expected_sha256 = KOTLIN_LSP_SHA256_BY_SUFFIX[kotlin_suffix] + expected_sha256 = _kotlin_lsp_sha(kotlin_lsp_version, kotlin_suffix) log.info("Downloading Kotlin Language Server...") FileUtils.download_and_extract_archive_verified( kotlin_url, diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py index ef5e8674..3176e41f 100644 --- a/src/solidlsp/language_servers/lua_ls.py +++ b/src/solidlsp/language_servers/lua_ls.py @@ -26,15 +26,42 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -LUA_LS_VERSION = "3.15.0" LUA_LS_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -LUA_LS_SHA256_BY_ASSET = { + +# 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_LUA_LS_VERSION = "3.15.0" +INITIAL_LUA_LS_SHA256_BY_ASSET = { "lua-language-server-3.15.0-linux-x64.tar.gz": "4877b874c52fb7587707898da9026cc3a6c854d9bbab115ef49ac4e6a1b88007", "lua-language-server-3.15.0-linux-arm64.tar.gz": "7dff8edfed4f34cf6325ff384791287d95f9a8dd9615a5279c7c6af81cf8c45d", "lua-language-server-3.15.0-darwin-x64.tar.gz": "01d28a31e264434e51662814a68f584af068393caecfa158c4df5f7fdc3ca2f7", "lua-language-server-3.15.0-darwin-arm64.tar.gz": "050f5f493f65112afc116e31281a9f73918546782d3696485dc052724838f58b", "lua-language-server-3.15.0-win32-x64.zip": "76a10c05e8c947a448f00a61acead4240484cd1e2e8c66d54401c67d99b77535", } +DEFAULT_LUA_LS_VERSION = "3.15.0" +DEFAULT_LUA_LS_SHA256_BY_ASSET = { + "lua-language-server-3.15.0-linux-x64.tar.gz": "4877b874c52fb7587707898da9026cc3a6c854d9bbab115ef49ac4e6a1b88007", + "lua-language-server-3.15.0-linux-arm64.tar.gz": "7dff8edfed4f34cf6325ff384791287d95f9a8dd9615a5279c7c6af81cf8c45d", + "lua-language-server-3.15.0-darwin-x64.tar.gz": "01d28a31e264434e51662814a68f584af068393caecfa158c4df5f7fdc3ca2f7", + "lua-language-server-3.15.0-darwin-arm64.tar.gz": "050f5f493f65112afc116e31281a9f73918546782d3696485dc052724838f58b", + "lua-language-server-3.15.0-win32-x64.zip": "76a10c05e8c947a448f00a61acead4240484cd1e2e8c66d54401c67d99b77535", +} + + +def _lua_ls_sha(version: str, asset_name: str) -> str | None: + if version == INITIAL_LUA_LS_VERSION: + return INITIAL_LUA_LS_SHA256_BY_ASSET.get(asset_name) + if version == DEFAULT_LUA_LS_VERSION: + return DEFAULT_LUA_LS_SHA256_BY_ASSET.get(asset_name) + return None + + +def _lua_ls_install_dir(ls_resources_dir: str, version: str) -> Path: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + if version == INITIAL_LUA_LS_VERSION: + return Path(ls_resources_dir) / "lua" + return Path(ls_resources_dir) / f"lua-{version}" class LuaLanguageServer(SolidLanguageServer): @@ -67,7 +94,9 @@ class LuaLanguageServer(SolidLanguageServer): ] if solidlsp_settings is not None: - ls_resource_dir = Path(LuaLanguageServer.ls_resources_dir(solidlsp_settings)) / "lua" + lua_settings = solidlsp_settings.get_ls_specific_settings(Language.LUA) + lua_ls_version = lua_settings.get("lua_language_server_version", DEFAULT_LUA_LS_VERSION) + ls_resource_dir = _lua_ls_install_dir(LuaLanguageServer.ls_resources_dir(solidlsp_settings), lua_ls_version) possible_paths.extend( [ ls_resource_dir / "bin" / "lua-language-server", @@ -93,7 +122,7 @@ class LuaLanguageServer(SolidLanguageServer): def _download_lua_ls(solidlsp_settings: SolidLSPSettings) -> str: """Download and install lua-language-server if not present.""" lua_settings = solidlsp_settings.get_ls_specific_settings(Language.LUA) - lua_ls_version = lua_settings.get("lua_language_server_version", LUA_LS_VERSION) + lua_ls_version = lua_settings.get("lua_language_server_version", DEFAULT_LUA_LS_VERSION) system = platform.system() machine = platform.machine().lower() @@ -122,7 +151,7 @@ class LuaLanguageServer(SolidLanguageServer): download_url = f"https://github.com/LuaLS/lua-language-server/releases/download/{lua_ls_version}/{download_name}" - install_dir = Path(LuaLanguageServer.ls_resources_dir(solidlsp_settings)) / "lua" + install_dir = _lua_ls_install_dir(LuaLanguageServer.ls_resources_dir(solidlsp_settings), lua_ls_version) install_dir.mkdir(parents=True, exist_ok=True) log.info("Downloading lua-language-server from %s", download_url) @@ -131,7 +160,7 @@ class LuaLanguageServer(SolidLanguageServer): download_url, str(install_dir), archive_type, - expected_sha256=LUA_LS_SHA256_BY_ASSET.get(download_name) if lua_ls_version == LUA_LS_VERSION else None, + expected_sha256=_lua_ls_sha(lua_ls_version, download_name), allowed_hosts=LUA_LS_ALLOWED_HOSTS, ) diff --git a/src/solidlsp/language_servers/luau_lsp.py b/src/solidlsp/language_servers/luau_lsp.py index 4150374d..f0cb06bf 100644 --- a/src/solidlsp/language_servers/luau_lsp.py +++ b/src/solidlsp/language_servers/luau_lsp.py @@ -37,15 +37,34 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -# Pin to a known stable release -LUAU_LSP_VERSION = "1.63.0" LUAU_LSP_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -LUAU_LSP_SHA256_BY_ASSET = { + +# 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_LUAU_LSP_VERSION = "1.63.0" +INITIAL_LUAU_LSP_SHA256_BY_ASSET = { "luau-lsp-linux-x86_64.zip": "e4b633ad9a2c15437f60f9e721263f79aa0da606867d8458f0e159a325bf2db8", "luau-lsp-linux-arm64.zip": "355be010f337a6772df6255c92e1fb28a59d194abe5c570453f4186472244355", "luau-lsp-macos.zip": "01c1d6dd5fee27295b2968915dabb08c192192c46d9fe9c97bf31a130c96b8cb", "luau-lsp-win64.zip": "eea596d47dc1c94a61ba1b78e6472bb4445bc3309780751515e6ab0a0abba57d", } +DEFAULT_LUAU_LSP_VERSION = "1.63.0" +DEFAULT_LUAU_LSP_SHA256_BY_ASSET = { + "luau-lsp-linux-x86_64.zip": "e4b633ad9a2c15437f60f9e721263f79aa0da606867d8458f0e159a325bf2db8", + "luau-lsp-linux-arm64.zip": "355be010f337a6772df6255c92e1fb28a59d194abe5c570453f4186472244355", + "luau-lsp-macos.zip": "01c1d6dd5fee27295b2968915dabb08c192192c46d9fe9c97bf31a130c96b8cb", + "luau-lsp-win64.zip": "eea596d47dc1c94a61ba1b78e6472bb4445bc3309780751515e6ab0a0abba57d", +} + + +def _luau_lsp_sha(version: str, asset_name: str) -> str | None: + if version == INITIAL_LUAU_LSP_VERSION: + return INITIAL_LUAU_LSP_SHA256_BY_ASSET.get(asset_name) + if version == DEFAULT_LUAU_LSP_VERSION: + return DEFAULT_LUAU_LSP_SHA256_BY_ASSET.get(asset_name) + return None + # Luau built-in docs CDN LUAU_DOCS_URL = "https://luau-lsp.pages.dev/api-docs/luau-en-us.json" @@ -98,14 +117,19 @@ class LuauLanguageServer(SolidLanguageServer): return cmd def _download_luau_lsp(self) -> str: - install_dir = Path(self._ls_resources_dir) + luau_lsp_version = self._custom_settings.get("luau_lsp_version", DEFAULT_LUAU_LSP_VERSION) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + Path(self._ls_resources_dir) + if luau_lsp_version == INITIAL_LUAU_LSP_VERSION + else Path(self._ls_resources_dir) / f"luau-lsp-{luau_lsp_version}" + ) install_dir.mkdir(parents=True, exist_ok=True) binary_path = self._find_existing_binary(install_dir) if binary_path is not None: return binary_path - luau_lsp_version = self._custom_settings.get("luau_lsp_version", LUAU_LSP_VERSION) asset_name = self._get_luau_lsp_asset_name() download_url = f"https://github.com/JohnnyMorganz/luau-lsp/releases/download/{luau_lsp_version}/{asset_name}" @@ -114,7 +138,7 @@ class LuauLanguageServer(SolidLanguageServer): download_url, str(install_dir), "zip", - expected_sha256=LUAU_LSP_SHA256_BY_ASSET.get(asset_name) if luau_lsp_version == LUAU_LSP_VERSION else None, + expected_sha256=_luau_lsp_sha(luau_lsp_version, asset_name), allowed_hosts=LUAU_LSP_ALLOWED_HOSTS, ) diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index affe2dcb..13c32147 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -28,6 +28,34 @@ log = logging.getLogger(__name__) MARKSMAN_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_MARKSMAN_VERSION = "2024-12-18" +INITIAL_MARKSMAN_SHA256_BY_PLATFORM = { + "linux-x64": "b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475", + "linux-arm64": "b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73", + "osx-x64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "osx-arm64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "win-x64": "39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec", +} +DEFAULT_MARKSMAN_VERSION = "2024-12-18" +DEFAULT_MARKSMAN_SHA256_BY_PLATFORM = { + "linux-x64": "b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475", + "linux-arm64": "b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73", + "osx-x64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "osx-arm64": "7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83", + "win-x64": "39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec", +} + + +def _marksman_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_MARKSMAN_VERSION: + return INITIAL_MARKSMAN_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_MARKSMAN_VERSION: + return DEFAULT_MARKSMAN_SHA256_BY_PLATFORM[platform_key] + return None + class Marksman(SolidLanguageServer): """ @@ -39,12 +67,9 @@ class Marksman(SolidLanguageServer): """ class DependencyProvider(LanguageServerDependencyProviderSinglePath): - DEFAULT_MARKSMAN_VERSION = "2024-12-18" - @classmethod def _runtime_dependencies(cls, version: str) -> RuntimeDependencyCollection: marksman_releases = f"https://github.com/artempyanykh/marksman/releases/download/{version}" - default_version = version == cls.DEFAULT_MARKSMAN_VERSION return RuntimeDependencyCollection( [ RuntimeDependency( @@ -53,7 +78,7 @@ class Marksman(SolidLanguageServer): platform_id="linux-x64", archive_type="binary", binary_name="marksman", - sha256="b9cb666c643dfd9b699811fdfc445ed4c56be65c1d878c21d46847f0d7b0e475" if default_version else None, + sha256=_marksman_sha(version, "linux-x64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), RuntimeDependency( @@ -62,7 +87,7 @@ class Marksman(SolidLanguageServer): platform_id="linux-arm64", archive_type="binary", binary_name="marksman", - sha256="b8d6972a56f3f9b7bbbf7c77ef8998e3b66fa82fb03c01398e224144486c9e73" if default_version else None, + sha256=_marksman_sha(version, "linux-arm64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), RuntimeDependency( @@ -71,7 +96,7 @@ class Marksman(SolidLanguageServer): platform_id="osx-x64", archive_type="binary", binary_name="marksman", - sha256="7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83" if default_version else None, + sha256=_marksman_sha(version, "osx-x64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), RuntimeDependency( @@ -80,7 +105,7 @@ class Marksman(SolidLanguageServer): platform_id="osx-arm64", archive_type="binary", binary_name="marksman", - sha256="7e18803966231a33ee107d0d26f69b41f2f0dc1332c52dd9729c2e29fb77be83" if default_version else None, + sha256=_marksman_sha(version, "osx-arm64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), RuntimeDependency( @@ -89,7 +114,7 @@ class Marksman(SolidLanguageServer): platform_id="win-x64", archive_type="binary", binary_name="marksman.exe", - sha256="39de9df039c8b0d627ac5918a9d8792ad20fc49e2461d1f5c906975c016799ec" if default_version else None, + sha256=_marksman_sha(version, "win-x64"), allowed_hosts=MARKSMAN_ALLOWED_HOSTS, ), ] @@ -97,11 +122,16 @@ class Marksman(SolidLanguageServer): def _get_or_install_core_dependency(self) -> str: """Setup runtime dependencies for marksman and return the command to start the server.""" - marksman_version = self._custom_settings.get("marksman_version", self.DEFAULT_MARKSMAN_VERSION) + marksman_version = self._custom_settings.get("marksman_version", DEFAULT_MARKSMAN_VERSION) deps = self._runtime_dependencies(marksman_version) dependency = deps.get_single_dep_for_current_platform() - marksman_ls_dir = self._ls_resources_dir + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + marksman_ls_dir = ( + self._ls_resources_dir + if marksman_version == INITIAL_MARKSMAN_VERSION + else os.path.join(self._ls_resources_dir, f"marksman-{marksman_version}") + ) marksman_executable_path = deps.binary_path(marksman_ls_dir) if not os.path.exists(marksman_executable_path): log.info( diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py index d31c2224..5a83a75f 100644 --- a/src/solidlsp/language_servers/matlab_language_server.py +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -50,11 +50,29 @@ log = logging.getLogger(__name__) # Environment variable for MATLAB installation path MATLAB_PATH_ENV_VAR = "MATLAB_PATH" -MATLAB_EXTENSION_VERSION = "1.3.9" -MATLAB_EXTENSION_URL = f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/vsextensions/language-matlab/{MATLAB_EXTENSION_VERSION}/vspackage" -MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2" MATLAB_EXTENSION_ALLOWED_HOSTS = ("marketplace.visualstudio.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_MATLAB_EXTENSION_VERSION = "1.3.9" +INITIAL_MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2" +DEFAULT_MATLAB_EXTENSION_VERSION = "1.3.9" +DEFAULT_MATLAB_EXTENSION_SHA256 = "1da3add2c3a593fa0ebcdf1d15231faee8014de10f549c36915ab9d4f18390f2" + + +def _matlab_extension_sha(version: str) -> str | None: + if version == INITIAL_MATLAB_EXTENSION_VERSION: + return INITIAL_MATLAB_EXTENSION_SHA256 + if version == DEFAULT_MATLAB_EXTENSION_VERSION: + return DEFAULT_MATLAB_EXTENSION_SHA256 + return None + + +def _matlab_extension_dirname(version: str) -> str: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + return "matlab-extension" if version == INITIAL_MATLAB_EXTENSION_VERSION else f"matlab-extension-{version}" + class MatlabLanguageServer(SolidLanguageServer): """ @@ -102,20 +120,12 @@ class MatlabLanguageServer(SolidLanguageServer): self._matlab_path: str | None = None @classmethod - def _download_matlab_extension(cls, url: str, target_dir: str) -> bool: + def _download_matlab_extension(cls, url: str, target_dir: str, expected_sha256: str | None) -> bool: """ Download and extract the MATLAB extension from VS Code marketplace. The VS Code marketplace packages extensions as .vsix files (which are ZIP archives). This method downloads the VSIX file and extracts it to get the language server. - - Args: - url: VS Code marketplace URL for the MATLAB extension - target_dir: Directory where the extension will be extracted - - Returns: - True if successful, False otherwise - """ try: log.info(f"Downloading MATLAB extension from {url}") @@ -124,7 +134,7 @@ class MatlabLanguageServer(SolidLanguageServer): url, target_dir, "zip", - expected_sha256=MATLAB_EXTENSION_SHA256 if url == MATLAB_EXTENSION_URL else None, + expected_sha256=expected_sha256, allowed_hosts=MATLAB_EXTENSION_ALLOWED_HOSTS, ) log.info("MATLAB extension extracted successfully") @@ -155,8 +165,9 @@ class MatlabLanguageServer(SolidLanguageServer): elif env_path: log.warning(f"MATLAB_EXTENSION_PATH set but directory not found: {env_path}") - # Check default download location - default_path = os.path.join(self._ls_resources_dir, "matlab-extension", "extension") + # Check the resolved-version download location (versioned for non-INITIAL, legacy "matlab-extension" for INITIAL) + matlab_extension_version = self._custom_settings.get("matlab_extension_version", DEFAULT_MATLAB_EXTENSION_VERSION) + default_path = os.path.join(self._ls_resources_dir, _matlab_extension_dirname(matlab_extension_version), "extension") if os.path.exists(default_path): log.debug(f"Found MATLAB extension in default location: {default_path}") return default_path @@ -182,8 +193,8 @@ class MatlabLanguageServer(SolidLanguageServer): Path to installed extension or None if download failed """ - matlab_extension_dir = os.path.join(self._ls_resources_dir, "matlab-extension") - matlab_extension_version = self._custom_settings.get("matlab_extension_version", MATLAB_EXTENSION_VERSION) + matlab_extension_version = self._custom_settings.get("matlab_extension_version", DEFAULT_MATLAB_EXTENSION_VERSION) + matlab_extension_dir = os.path.join(self._ls_resources_dir, _matlab_extension_dirname(matlab_extension_version)) matlab_extension_url = ( "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/MathWorks/" f"vsextensions/language-matlab/{matlab_extension_version}/vspackage" @@ -191,7 +202,7 @@ class MatlabLanguageServer(SolidLanguageServer): log.info(f"Downloading MATLAB extension from: {matlab_extension_url}") - if self._download_matlab_extension(matlab_extension_url, matlab_extension_dir): + if self._download_matlab_extension(matlab_extension_url, matlab_extension_dir, _matlab_extension_sha(matlab_extension_version)): extension_path = os.path.join(matlab_extension_dir, "extension") if os.path.exists(extension_path): log.info("MATLAB extension downloaded and installed successfully") diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py index ddd21698..f4ece965 100644 --- a/src/solidlsp/language_servers/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -22,6 +22,11 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) OMNISHARP_ALLOWED_HOSTS = ("roslynomnisharp.blob.core.windows.net", "download.visualstudio.microsoft.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_OMNISHARP_VERSION = "1.39.10" +INITIAL_RAZOR_OMNISHARP_VERSION = "7.0.0-preview.23363.1" DEFAULT_OMNISHARP_VERSION = "1.39.10" DEFAULT_RAZOR_OMNISHARP_VERSION = "7.0.0-preview.23363.1" @@ -168,11 +173,11 @@ class OmniSharp(SolidLanguageServer): dependency["installPath"] = dependency["installPath"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version) if "installTestPath" in dependency: dependency["installTestPath"] = dependency["installTestPath"].replace(DEFAULT_OMNISHARP_VERSION, omnisharp_version) - if omnisharp_version != DEFAULT_OMNISHARP_VERSION: + if omnisharp_version not in (INITIAL_OMNISHARP_VERSION, DEFAULT_OMNISHARP_VERSION): dependency["integrity"] = None elif dependency["id"] == "RazorOmnisharp": dependency["url"] = dependency["url"].replace(DEFAULT_RAZOR_OMNISHARP_VERSION, razor_omnisharp_version) - if razor_omnisharp_version != DEFAULT_RAZOR_OMNISHARP_VERSION: + if razor_omnisharp_version not in (INITIAL_RAZOR_OMNISHARP_VERSION, DEFAULT_RAZOR_OMNISHARP_VERSION): dependency["integrity"] = None assert platform_id in [ @@ -207,7 +212,9 @@ class OmniSharp(SolidLanguageServer): assert "OmniSharp" in runtime_dependencies assert "RazorOmnisharp" in runtime_dependencies - omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "OmniSharp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + omnisharp_dirname = "OmniSharp" if omnisharp_version == INITIAL_OMNISHARP_VERSION else f"OmniSharp-{omnisharp_version}" + omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), omnisharp_dirname) if not os.path.exists(omnisharp_ls_dir): os.makedirs(omnisharp_ls_dir) FileUtils.download_and_extract_archive_verified( @@ -221,7 +228,10 @@ class OmniSharp(SolidLanguageServer): assert os.path.exists(omnisharp_executable_path) os.chmod(omnisharp_executable_path, 0o755) - razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "RazorOmnisharp") + razor_dirname = ( + "RazorOmnisharp" if razor_omnisharp_version == INITIAL_RAZOR_OMNISHARP_VERSION else f"RazorOmnisharp-{razor_omnisharp_version}" + ) + razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), razor_dirname) if not os.path.exists(razor_omnisharp_ls_dir): os.makedirs(razor_omnisharp_ls_dir) FileUtils.download_and_extract_archive_verified( diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py index 1f2b7de7..34c6ff73 100644 --- a/src/solidlsp/language_servers/pascal_server.py +++ b/src/solidlsp/language_servers/pascal_server.py @@ -69,7 +69,34 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -PASLS_VERSION = "v0.2.0" +# 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_PASLS_VERSION = "v0.2.0" +INITIAL_PASLS_SHA256_BY_PLATFORM = { + "linux-x64": "517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b", + "linux-arm64": "cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502", + "osx-x64": "0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1", + "osx-arm64": "d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7", + "win-x64": "1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5", +} +DEFAULT_PASLS_VERSION = "v0.2.0" +DEFAULT_PASLS_SHA256_BY_PLATFORM = { + "linux-x64": "517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b", + "linux-arm64": "cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502", + "osx-x64": "0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1", + "osx-arm64": "d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7", + "win-x64": "1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5", +} +PASLS_VERSION = DEFAULT_PASLS_VERSION + + +def _pasls_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_PASLS_VERSION: + return INITIAL_PASLS_SHA256_BY_PLATFORM.get(platform_key) + if version == DEFAULT_PASLS_VERSION: + return DEFAULT_PASLS_SHA256_BY_PLATFORM.get(platform_key) + return None class PascalLanguageServer(SolidLanguageServer): @@ -629,7 +656,12 @@ class PascalLanguageServer(SolidLanguageServer): log.info(f"Found pasls in PATH: {pasls_in_path}") return quote_windows_path(pasls_in_path) - pasls_dir = cls.ls_resources_dir(solidlsp_settings) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + pasls_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if pasls_version == INITIAL_PASLS_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"pasls-{pasls_version}") + ) os.makedirs(pasls_dir, exist_ok=True) # Clean up old files from previous sessions @@ -647,7 +679,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="linux-x64", archive_type="gztar", binary_name="pasls", - sha256="517259395b0a385a5e848cf48b967645a984be3dd456118bc08771283a822a5b", + sha256=_pasls_sha(pasls_version, "linux-x64"), ), RuntimeDependency( id="PascalLanguageServer", @@ -656,7 +688,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="linux-arm64", archive_type="gztar", binary_name="pasls", - sha256="cb4986941cfdcf9cb74ece6bbb53a443390a908e880108a37f4ccf82b2d6c502", + sha256=_pasls_sha(pasls_version, "linux-arm64"), ), RuntimeDependency( id="PascalLanguageServer", @@ -665,7 +697,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="osx-x64", archive_type="zip", binary_name="pasls", - sha256="0abfcd98f63f77dba74094339a40d4407b69317c1c77b13a26b9b7dbdfd885f1", + sha256=_pasls_sha(pasls_version, "osx-x64"), ), RuntimeDependency( id="PascalLanguageServer", @@ -674,7 +706,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="osx-arm64", archive_type="zip", binary_name="pasls", - sha256="d4c2411e406af96ceae12b11e77fdb0c684ca15a68bfd8b4f9c6fe1fbdf515a7", + sha256=_pasls_sha(pasls_version, "osx-arm64"), ), RuntimeDependency( id="PascalLanguageServer", @@ -683,7 +715,7 @@ class PascalLanguageServer(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="pasls.exe", - sha256="1493c31552e6f90a59800b2d44669e01fc4551d3647f3cea5dd105a9f6bc73e5", + sha256=_pasls_sha(pasls_version, "win-x64"), ), ] ) diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py index 80805c0d..181cb3ee 100644 --- a/src/solidlsp/language_servers/phpactor.py +++ b/src/solidlsp/language_servers/phpactor.py @@ -20,11 +20,24 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -PHPACTOR_VERSION = "2025.12.21.1" -PHPACTOR_PHAR_URL = f"https://github.com/phpactor/phpactor/releases/download/{PHPACTOR_VERSION}/phpactor.phar" -PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3" PHPACTOR_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_PHPACTOR_VERSION = "2025.12.21.1" +INITIAL_PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3" +DEFAULT_PHPACTOR_VERSION = "2025.12.21.1" +DEFAULT_PHPACTOR_PHAR_SHA256 = "53bbe9625cd9b5e9b394bc2f595fbad13dbbe6dfc96950c56dea3b5d9a246cc3" + + +def _phpactor_sha(version: str) -> str | None: + if version == INITIAL_PHPACTOR_VERSION: + return INITIAL_PHPACTOR_PHAR_SHA256 + if version == DEFAULT_PHPACTOR_VERSION: + return DEFAULT_PHPACTOR_PHAR_SHA256 + return None + class PhpactorServer(SolidLanguageServer): """ @@ -48,7 +61,7 @@ class PhpactorServer(SolidLanguageServer): """ Setup runtime dependencies for Phpactor and return the path to the PHAR file. """ - phpactor_version = self._custom_settings.get("phpactor_version", PHPACTOR_VERSION) + phpactor_version = self._custom_settings.get("phpactor_version", DEFAULT_PHPACTOR_VERSION) phpactor_phar_url = f"https://github.com/phpactor/phpactor/releases/download/{phpactor_version}/phpactor.phar" # Verify PHP is installed php_path = shutil.which("php") @@ -68,15 +81,20 @@ class PhpactorServer(SolidLanguageServer): else: log.warning("Could not parse PHP version from output. Continuing anyway.") - phpactor_phar_path = os.path.join(self._ls_resources_dir, "phpactor.phar") + # legacy unversioned phar at root reserved for INITIAL; every other version goes into a versioned subdir + if phpactor_version == INITIAL_PHPACTOR_VERSION: + phar_dir = self._ls_resources_dir + else: + phar_dir = os.path.join(self._ls_resources_dir, f"phpactor-{phpactor_version}") + phpactor_phar_path = os.path.join(phar_dir, "phpactor.phar") if not os.path.exists(phpactor_phar_path): - os.makedirs(self._ls_resources_dir, exist_ok=True) + os.makedirs(phar_dir, exist_ok=True) log.info(f"Downloading phpactor PHAR from {phpactor_phar_url}") FileUtils.download_and_extract_archive_verified( phpactor_phar_url, phpactor_phar_path, "binary", - expected_sha256=PHPACTOR_PHAR_SHA256 if phpactor_version == PHPACTOR_VERSION else None, + expected_sha256=_phpactor_sha(phpactor_version), allowed_hosts=PHPACTOR_ALLOWED_HOSTS, ) diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index e737b09a..70a06d1a 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -34,8 +34,30 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) # PowerShell Editor Services version to download -PSES_VERSION = "4.4.0" -PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" +# 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_PSES_VERSION = "4.4.0" +INITIAL_PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" +DEFAULT_PSES_VERSION = "4.4.0" +DEFAULT_PSES_SHA256 = "690b91092989a0f66e6f43986166aaef69d64b559a9fda51feed882e1103fbcc" + + +def _pses_sha(version: str) -> str | None: + if version == INITIAL_PSES_VERSION: + return INITIAL_PSES_SHA256 + if version == DEFAULT_PSES_VERSION: + return DEFAULT_PSES_SHA256 + return None + + +def _pses_install_dir(ls_resources_dir: str, version: str) -> Path: + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + if version == INITIAL_PSES_VERSION: + return Path(ls_resources_dir) / "powershell" + return Path(ls_resources_dir) / f"powershell-{version}" + + PSES_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") PSSCRIPTANALYZER_VERSION = "1.25.0" @@ -99,7 +121,9 @@ class PowerShellLanguageServer(SolidLanguageServer): @classmethod def _get_pses_path(cls, solidlsp_settings: SolidLSPSettings) -> str | None: """Get the path to PowerShell Editor Services installation.""" - install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell" + ps_settings = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL) + pses_version = ps_settings.get("pses_version", DEFAULT_PSES_VERSION) + install_dir = _pses_install_dir(cls.ls_resources_dir(solidlsp_settings), pses_version) start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1" if start_script.exists(): @@ -111,13 +135,13 @@ class PowerShellLanguageServer(SolidLanguageServer): def _download_pses(cls, solidlsp_settings: SolidLSPSettings) -> str: """Download and install PowerShell Editor Services.""" ps_settings = solidlsp_settings.get_ls_specific_settings(Language.POWERSHELL) - pses_version = ps_settings.get("pses_version", PSES_VERSION) + pses_version = ps_settings.get("pses_version", DEFAULT_PSES_VERSION) download_url = ( f"https://github.com/PowerShell/PowerShellEditorServices/releases/download/v{pses_version}/PowerShellEditorServices.zip" ) - # Create installation directory - install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell" + # Create installation directory; legacy unversioned dir reserved for INITIAL only + install_dir = _pses_install_dir(cls.ls_resources_dir(solidlsp_settings), pses_version) install_dir.mkdir(parents=True, exist_ok=True) log.info(f"Downloading PowerShell Editor Services from {download_url}...") @@ -125,7 +149,7 @@ class PowerShellLanguageServer(SolidLanguageServer): download_url, str(install_dir), "zip", - expected_sha256=PSES_SHA256 if pses_version == PSES_VERSION else None, + expected_sha256=_pses_sha(pses_version), allowed_hosts=PSES_ALLOWED_HOSTS, ) diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index a41deb89..d0c34053 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -24,6 +24,14 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# 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_SOLIDITY_LANGUAGE_SERVER_VERSION = "0.8.4" +DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION = "0.8.4" +INITIAL_FORGE_VERSION = "1.5.1" +DEFAULT_FORGE_VERSION = "1.5.1" + class SolidityLanguageServer(SolidLanguageServer): """ @@ -73,8 +81,10 @@ class SolidityLanguageServer(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install Node.js and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - solidity_language_server_version = self._custom_settings.get("solidity_language_server_version", "0.8.4") - forge_version = self._custom_settings.get("forge_version", "1.5.1") + solidity_language_server_version = self._custom_settings.get( + "solidity_language_server_version", DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION + ) + forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION) npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -101,7 +111,7 @@ class SolidityLanguageServer(SolidLanguageServer): ] ) - solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp") + solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version) managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") solidity_executable_path = os.path.join(managed_bin_dir, "nomicfoundation-solidity-language-server") forge_executable_path = os.path.join(managed_bin_dir, "forge") @@ -146,10 +156,22 @@ class SolidityLanguageServer(SolidLanguageServer): return f"@foundry-rs/forge-linux-{arch}" def create_launch_command_env(self) -> dict[str, str]: - solidity_ls_dir = os.path.join(self._ls_resources_dir, "solidity-lsp") + solidity_language_server_version = self._custom_settings.get( + "solidity_language_server_version", DEFAULT_SOLIDITY_LANGUAGE_SERVER_VERSION + ) + forge_version = self._custom_settings.get("forge_version", DEFAULT_FORGE_VERSION) + solidity_ls_dir = self._resolve_solidity_ls_dir(solidity_language_server_version, forge_version) managed_bin_dir = os.path.join(solidity_ls_dir, "node_modules", ".bin") return {"PATH": managed_bin_dir + os.pathsep + os.environ.get("PATH", "")} + def _resolve_solidity_ls_dir(self, solidity_language_server_version: str, forge_version: str) -> str: + # legacy unversioned dir reserved for INITIAL pair; any other combination goes into a versioned subdir + is_initial = ( + solidity_language_server_version == INITIAL_SOLIDITY_LANGUAGE_SERVER_VERSION and forge_version == INITIAL_FORGE_VERSION + ) + ls_dirname = "solidity-lsp" if is_initial else f"solidity-lsp-{solidity_language_server_version}-{forge_version}" + return os.path.join(self._ls_resources_dir, ls_dirname) + def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py index 410edde1..ee9be952 100644 --- a/src/solidlsp/language_servers/taplo_server.py +++ b/src/solidlsp/language_servers/taplo_server.py @@ -24,15 +24,24 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) -# Taplo release version and download URLs -TAPLO_VERSION = "0.10.0" -TAPLO_DOWNLOAD_BASE = f"https://github.com/tamasfe/taplo/releases/download/{TAPLO_VERSION}" TAPLO_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -# SHA256 checksums for Taplo releases (verified from official GitHub releases) -# Source: https://github.com/tamasfe/taplo/releases/tag/0.10.0 -# To update: download each release file and run: sha256sum -TAPLO_SHA256_CHECKSUMS: dict[str, str] = { +# 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. +# To update: download each release file and run: sha256sum , copy values into DEFAULT_*. +INITIAL_TAPLO_VERSION = "0.10.0" +INITIAL_TAPLO_SHA256_CHECKSUMS: dict[str, str] = { + "taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9", + "taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0", + "taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2", + "taplo-darwin-aarch64.gz": "713734314c3e71894b9e77513c5349835eefbd52908445a0d73b0c7dc469347d", + "taplo-linux-x86_64.gz": "8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156", + "taplo-linux-aarch64.gz": "033681d01eec8376c3fd38fa3703c79316f5e14bb013d859943b60a07bccdcc3", + "taplo-linux-armv7.gz": "6b728896afe2573522f38b8e668b1ff40eb5928fd9d6d0c253ecae508274d417", +} +DEFAULT_TAPLO_VERSION = "0.10.0" +DEFAULT_TAPLO_SHA256_CHECKSUMS: dict[str, str] = { "taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9", "taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0", "taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2", @@ -43,7 +52,15 @@ TAPLO_SHA256_CHECKSUMS: dict[str, str] = { } -def _get_taplo_download_url(version: str = TAPLO_VERSION) -> tuple[str, str]: +def _taplo_sha(version: str, archive_filename: str) -> str | None: + if version == INITIAL_TAPLO_VERSION: + return INITIAL_TAPLO_SHA256_CHECKSUMS.get(archive_filename) + if version == DEFAULT_TAPLO_VERSION: + return DEFAULT_TAPLO_SHA256_CHECKSUMS.get(archive_filename) + return None + + +def _get_taplo_download_url(version: str = DEFAULT_TAPLO_VERSION) -> tuple[str, str]: """ Get the appropriate Taplo download URL for the current platform. @@ -130,11 +147,11 @@ class TaploServer(SolidLanguageServer): log.info(f"Using system-installed Taplo at: {system_taplo}") return system_taplo - # Setup local installation directory - taplo_dir = os.path.join(self._ls_resources_dir, "taplo") + # Setup local installation directory; legacy unversioned dir reserved for INITIAL only + taplo_version = self._custom_settings.get("taplo_version", DEFAULT_TAPLO_VERSION) + ls_dirname = "taplo" if taplo_version == INITIAL_TAPLO_VERSION else f"taplo-{taplo_version}" + taplo_dir = os.path.join(self._ls_resources_dir, ls_dirname) os.makedirs(taplo_dir, exist_ok=True) - - taplo_version = self._custom_settings.get("taplo_version", TAPLO_VERSION) _, executable_name = _get_taplo_download_url(taplo_version) taplo_executable = os.path.join(taplo_dir, executable_name) @@ -158,12 +175,13 @@ class TaploServer(SolidLanguageServer): return [core_path, "lsp", "stdio"] @classmethod - def _download_taplo(cls, install_dir: str, executable_path: str, version: str = TAPLO_VERSION) -> None: + def _download_taplo(cls, install_dir: str, executable_path: str, version: str = DEFAULT_TAPLO_VERSION) -> None: """Download and extract Taplo binary using the shared verified download helper.""" download_url, _ = _get_taplo_download_url(version) archive_filename = os.path.basename(download_url) - expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename) if version == TAPLO_VERSION else None - if expected_hash is None and version == TAPLO_VERSION: + # only verify the SHA when the resolved version is one of our pinned ones (INITIAL or current DEFAULT) + expected_hash = _taplo_sha(version, archive_filename) + if expected_hash is None and version in (INITIAL_TAPLO_VERSION, DEFAULT_TAPLO_VERSION): raise RuntimeError(f"No SHA256 checksum configured for Taplo archive: {archive_filename}") try: diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index ec26126d..c654fd95 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -18,6 +18,34 @@ log = logging.getLogger(__name__) TERRAFORM_LS_ALLOWED_HOSTS = ("releases.hashicorp.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_TERRAFORM_LS_VERSION = "0.36.5" +INITIAL_TERRAFORM_LS_SHA256_BY_PLATFORM = { + "osx-arm64": "fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c", + "osx-x64": "17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a", + "linux-arm64": "724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7", + "linux-x64": "37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44", + "win-x64": "a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec", +} +DEFAULT_TERRAFORM_LS_VERSION = "0.36.5" +DEFAULT_TERRAFORM_LS_SHA256_BY_PLATFORM = { + "osx-arm64": "fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c", + "osx-x64": "17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a", + "linux-arm64": "724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7", + "linux-x64": "37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44", + "win-x64": "a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec", +} + + +def _terraform_ls_sha(version: str, platform_key: str) -> str | None: + if version == INITIAL_TERRAFORM_LS_VERSION: + return INITIAL_TERRAFORM_LS_SHA256_BY_PLATFORM[platform_key] + if version == DEFAULT_TERRAFORM_LS_VERSION: + return DEFAULT_TERRAFORM_LS_SHA256_BY_PLATFORM[platform_key] + return None + class TerraformLS(SolidLanguageServer): """ @@ -99,7 +127,7 @@ class TerraformLS(SolidLanguageServer): """ cls._ensure_tf_command_available() terraform_settings = solidlsp_settings.get_ls_specific_settings(Language.TERRAFORM) - terraform_ls_version = terraform_settings.get("terraform_ls_version", "0.36.5") + terraform_ls_version = terraform_settings.get("terraform_ls_version", DEFAULT_TERRAFORM_LS_VERSION) platform_id = PlatformUtils.get_platform_id() deps = RuntimeDependencyCollection( [ @@ -110,7 +138,7 @@ class TerraformLS(SolidLanguageServer): platform_id="osx-arm64", archive_type="zip", binary_name="terraform-ls", - sha256="fee8743aa71fe2d8b0b9b91283b844cfa57d58457306a62e53a8f38d143cec8c" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "osx-arm64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), RuntimeDependency( @@ -120,7 +148,7 @@ class TerraformLS(SolidLanguageServer): platform_id="osx-x64", archive_type="zip", binary_name="terraform-ls", - sha256="17c5c480f8eec7e528292565f1c05d5097a41edf7ef8ee2a9f3a18d288a1415a" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "osx-x64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), RuntimeDependency( @@ -130,7 +158,7 @@ class TerraformLS(SolidLanguageServer): platform_id="linux-arm64", archive_type="zip", binary_name="terraform-ls", - sha256="724f45029f32d02d88b1952c7d1526c59fc8cd5dae49e31b9fed676a83f6cae7" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "linux-arm64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), RuntimeDependency( @@ -140,7 +168,7 @@ class TerraformLS(SolidLanguageServer): platform_id="linux-x64", archive_type="zip", binary_name="terraform-ls", - sha256="37e645cc54fd03e863157e2a3e773e7a5ff1d6cb3d045e4c20860cac1f550a44" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "linux-x64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), RuntimeDependency( @@ -150,17 +178,23 @@ class TerraformLS(SolidLanguageServer): platform_id="win-x64", archive_type="zip", binary_name="terraform-ls.exe", - sha256="a9223462cac9e1c0e6ba33043fbf9fb4483609b6970b5681a6306b04366698ec" if terraform_ls_version == "0.36.5" else None, + sha256=_terraform_ls_sha(terraform_ls_version, "win-x64"), allowed_hosts=TERRAFORM_LS_ALLOWED_HOSTS, ), ] ) dependency = deps.get_single_dep_for_current_platform() - terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir(solidlsp_settings)) + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + install_dir = ( + cls.ls_resources_dir(solidlsp_settings) + if terraform_ls_version == INITIAL_TERRAFORM_LS_VERSION + else os.path.join(cls.ls_resources_dir(solidlsp_settings), f"terraform-ls-{terraform_ls_version}") + ) + terraform_ls_executable_path = deps.binary_path(install_dir) if not os.path.exists(terraform_ls_executable_path): log.info(f"Downloading terraform-ls from {dependency.url}") - deps.install(cls.ls_resources_dir(solidlsp_settings)) + deps.install(install_dir) assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 6f3a443b..77f919d8 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -23,6 +23,14 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in log = logging.getLogger(__name__) +# 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_TYPESCRIPT_VERSION = "5.9.3" +DEFAULT_TYPESCRIPT_VERSION = "5.9.3" +INITIAL_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3" +DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION = "5.1.3" + # Platform-specific imports if os.name != "nt": # Unix-like systems import pwd @@ -154,8 +162,10 @@ class TypeScriptLanguageServer(SolidLanguageServer): # Get version settings from ls_specific_settings or use defaults language_specific_config = self._custom_settings - typescript_version = language_specific_config.get("typescript_version", "5.9.3") - typescript_language_server_version = language_specific_config.get("typescript_language_server_version", "5.1.3") + typescript_version = language_specific_config.get("typescript_version", DEFAULT_TYPESCRIPT_VERSION) + typescript_language_server_version = language_specific_config.get( + "typescript_language_server_version", DEFAULT_TYPESCRIPT_LANGUAGE_SERVER_VERSION + ) npm_registry = language_specific_config.get("npm_registry") deps = RuntimeDependencyCollection( @@ -181,38 +191,19 @@ class TypeScriptLanguageServer(SolidLanguageServer): is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - # Install typescript and typescript-language-server if not already installed or version mismatch - tsserver_ls_dir = os.path.join(self._ls_resources_dir, "ts-lsp") + # legacy unversioned dir reserved for INITIAL pair; any other version combination goes into a versioned subdir + is_initial = ( + typescript_version == INITIAL_TYPESCRIPT_VERSION + and typescript_language_server_version == INITIAL_TYPESCRIPT_LANGUAGE_SERVER_VERSION + ) + ls_dirname = "ts-lsp" if is_initial else f"ts-lsp-{typescript_version}-{typescript_language_server_version}" + tsserver_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") - # Check if installation is needed based on executable AND version - version_file = os.path.join(tsserver_ls_dir, ".installed_version") - expected_version = f"{typescript_version}_{typescript_language_server_version}" - - needs_install = False if not os.path.exists(tsserver_executable_path): - log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}.") - needs_install = True - elif os.path.exists(version_file): - with open(version_file) as f: - installed_version = f.read().strip() - if installed_version != expected_version: - log.info( - f"TypeScript Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." - ) - needs_install = True - else: - # No version file exists, assume old installation needs refresh - log.info("TypeScript Language Server version file not found. Reinstalling to ensure correct version...") - needs_install = True - - if needs_install: - log.info("Installing TypeScript Language Server dependencies...") + log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...") with LogTime("Installation of TypeScript language server dependencies", logger=log): deps.install(tsserver_ls_dir) - # Write version marker file - with open(version_file, "w") as f: - f.write(expected_version) log.info("TypeScript language server dependencies installed successfully") if not os.path.exists(tsserver_executable_path): diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index dc6db249..2464338a 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -24,6 +24,12 @@ from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_in log = logging.getLogger(__name__) +# 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_VTSLS_VERSION = "0.2.9" +DEFAULT_VTSLS_VERSION = "0.2.9" + class VtsLanguageServer(SolidLanguageServer): """ @@ -73,7 +79,7 @@ class VtsLanguageServer(SolidLanguageServer): ] assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for vtsls at the moment" vts_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT_VTS) - vtsls_version = vts_config.get("vtsls_version", "0.2.9") + vtsls_version = vts_config.get("vtsls_version", DEFAULT_VTSLS_VERSION) npm_registry = vts_config.get("npm_registry") deps = RuntimeDependencyCollection( @@ -86,7 +92,9 @@ class VtsLanguageServer(SolidLanguageServer): ), ] ) - vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vts-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = "vts-lsp" if vtsls_version == INITIAL_VTSLS_VERSION else f"vts-lsp-{vtsls_version}" + vts_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), ls_dirname) vts_executable_path = os.path.join(vts_ls_dir, "vtsls") # Verify both node and npm are installed diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py index 8a471921..b52c760e 100644 --- a/src/solidlsp/language_servers/yaml_language_server.py +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -17,6 +17,12 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# 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_YAML_LANGUAGE_SERVER_VERSION = "1.19.2" +DEFAULT_YAML_LANGUAGE_SERVER_VERSION = "1.19.2" + class YamlLanguageServer(SolidLanguageServer): """ @@ -66,7 +72,7 @@ class YamlLanguageServer(SolidLanguageServer): assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - yaml_language_server_version = self._custom_settings.get("yaml_language_server_version", "1.19.2") + yaml_language_server_version = self._custom_settings.get("yaml_language_server_version", DEFAULT_YAML_LANGUAGE_SERVER_VERSION) npm_registry = self._custom_settings.get("npm_registry") deps = RuntimeDependencyCollection( @@ -80,8 +86,13 @@ class YamlLanguageServer(SolidLanguageServer): ] ) - # Install yaml-language-server if not already installed - yaml_ls_dir = os.path.join(self._ls_resources_dir, "yaml-lsp") + # legacy unversioned dir reserved for INITIAL; every other version goes into a versioned subdir + ls_dirname = ( + "yaml-lsp" + if yaml_language_server_version == INITIAL_YAML_LANGUAGE_SERVER_VERSION + else f"yaml-lsp-{yaml_language_server_version}" + ) + yaml_ls_dir = os.path.join(self._ls_resources_dir, ls_dirname) yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server") # Handle Windows executable extension From 3a0afadf478d4b53f2e943811066fc33a37d6580 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 2 May 2026 20:53:20 +0200 Subject: [PATCH 47/48] Improve project creation/config docs --- docs/02-usage/040_workflow.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index ce8fbb77..219d33c2 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -18,20 +18,22 @@ setting up a project with Serena typically involves the following steps: Project creation is the process of defining fundamental project settings that are relevant to Serena's operation. You can create a project either + * explicitly, using the project creation command (see below), or * implicitly, by just activating a directory as a project while already in a conversation; this will use default settings for your project (skip to the next section). - * explicitly, using the project creation command, or ### Explicit Project Creation To explicitly create a project, use the following command while in the project directory: - serena project create [options] + serena project create [options] [project directory] - * For an empty project, you will need to specify the programming language - (e.g., `--language python`). - * For an existing project, the main programming language will be detected automatically, - but you can choose to explicitly specify multiple languages by passing the `--language` parameter - multiple times (e.g. `--language python --language typescript`). + * The project directory defaults to the current directory if not specified. + * For an existing project, the programming languages will be detected based on + the source files present, and the main language will be activated automatically. + If multiple languages are detected, you will be prompted whether you want to enable them. + * For an empty project, you can optionally specify one or more languages + to be activated explicitly via the `--language` parameter + (e.g. `--language python --language typescript`). * You can optionally specify a custom project name with `--name my-name`. * You can immediately index the project after creation with `--index`. @@ -42,6 +44,7 @@ After creation, you can adjust the project settings in the generated `.serena/pr within the project directory. The file allows you to configure ... + * the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project) * the set of programming languages for which language servers are spawned (not relevant when using the JetBrains plugin) Note that you can dynamically add/remove language servers while Serena is running via the [Dashboard](060_dashboard). * the [language backend](per-project-language-backend) to use for this project (overriding the global setting) @@ -49,8 +52,8 @@ The file allows you to configure ... * ignore rules * write access * an initial prompt that shall be passed to the LLM whenever the project is activated - * the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project) - * the set of tools and modes to use by default + * the set of tools and modes to use for the project + * and some other settings. 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). From 16d1a303971d28ea23a7c4c5cfcc3d2be74db4af Mon Sep 17 00:00:00 2001 From: Sam Carson Date: Mon, 4 May 2026 04:27:09 -0500 Subject: [PATCH 48/48] hooks: extend auto-approve to Claude Code's `auto` permission mode (#1445) * hooks: extend auto-approve to Claude Code's auto permission mode Closes #1386 --------- Co-authored-by: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> --- CHANGELOG.md | 4 +++ docs/02-usage/030_clients.md | 6 ++-- src/serena/hooks.py | 39 +++++++++++++++-------- test/serena/test_hooks.py | 61 +++++++++++++++++++++++++++++++++++- 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d8f79c..abdc5f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. +* Hooks: + - `serena-hooks auto-approve` now also emits an `allow` decision when Claude Code reports + `permission_mode == "auto"`, in addition to the existing `acceptEdits` behavior. #1386 + * General: - Breaking change in mode definitions: Projects (project.yml) can no longer override `base_modes`. Instead, they can define `added_modes` to add modes on top of base and default modes. diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 646873b1..cbfe0d4e 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -218,9 +218,9 @@ The hooks will: `grep` or `read_file` calls without using any Serena tools in between. - **`activate`**: Prompt the agent to activate the project and read Serena's instructions at session start. - **`cleanup`**: Clean up hook session data when the session ends. -- **`auto-approve`**: Auto-approve Serena tool calls whenever Claude Code is in `acceptEdits` mode, - so blanket edit approvals cover Serena's destructive tools (e.g. `replace_symbol_body`, - `rename_symbol`) instead of prompting on every call. +- **`auto-approve`**: Auto-approve Serena tool calls whenever Claude Code 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. For more details on Claude Code's hook system, see the [Claude Code hooks documentation](https://code.claude.com/docs/en/hooks). diff --git a/src/serena/hooks.py b/src/serena/hooks.py index a9322193..b1becc04 100644 --- a/src/serena/hooks.py +++ b/src/serena/hooks.py @@ -359,29 +359,41 @@ class SessionEndCleanupHook(Hook): class PreToolUseAutoApproveSerenaHook(PreToolUseHook): - """Pre-tool-use hook that auto-approves Serena tool calls while the client is in accept-edits mode. + """Pre-tool-use hook that auto-approves Serena tool calls while the client is in a permissive permission mode. - Claude Code's ``acceptEdits`` permission mode only applies to its built-in editing tools; - Serena's destructive tools (e.g. ``replace_symbol_body`` or ``rename_symbol``) would still - prompt the user on every call. This hook emits an ``allow`` decision for any Serena MCP - tool call whenever the client reports ``acceptEdits`` as the active permission mode, so - blanket edit approvals also cover Serena's tools. In all other situations it stays silent, - preserving the default approval flow. + Claude Code's permissive permission modes (``acceptEdits`` for blanket edit approval and + ``auto`` for hands-off autonomous execution) only apply to its built-in editing tools or + its auto-mode classifier; Serena's destructive tools (e.g. ``replace_symbol_body`` or + ``rename_symbol``) would still prompt the user on every call. This hook emits an ``allow`` + decision for any Serena MCP tool call whenever the client reports one of these modes as + the active permission mode, so blanket approvals also cover Serena's tools. In all other + situations it stays silent, preserving the default approval flow. + + ``bypassPermissions`` and ``dontAsk`` are deliberately excluded. ``bypassPermissions`` + already approves everything before the hook would matter, so silence here is harmless. + ``dontAsk`` is the user's deliberate deny-by-default posture (auto-deny unless an explicit + allow rule matches); the hook honors that choice and stays silent rather than blanket + overriding it. """ - _ACCEPT_EDITS_MODE = "acceptEdits" + #: permission modes for which this hook emits an ``allow`` decision. Frozen so the + #: set is immutable at the class level; matching is exact (case-sensitive) against + #: the canonical mode strings emitted by Claude Code's hook payload. + _AUTO_APPROVE_MODES: frozenset[str] = frozenset({"acceptEdits", "auto"}) - def is_accept_edits_mode(self) -> bool: - return self._permission_mode == self._ACCEPT_EDITS_MODE + def is_auto_approve_mode(self) -> bool: + return self._permission_mode in self._AUTO_APPROVE_MODES def execute(self) -> None: # only emit a decision when both the tool and the mode match; stay silent otherwise - if not self.is_serena_tool() or not self.is_accept_edits_mode(): + if not self.is_serena_tool() or not self.is_auto_approve_mode(): return + # name the actual mode in the reason so logs/debug output are unambiguous + # (the same hook handles multiple modes now) output_data = self.OutputData( permission_decision="allow", - permission_decision_reason="Auto-approved: Serena tool call while client is in acceptEdits mode.", + permission_decision_reason=f"Auto-approved: Serena tool call while client is in {self._permission_mode} mode.", ) click.echo(output_data.to_json_string()) @@ -426,7 +438,8 @@ class HookCommands(AutoRegisteringGroup): @staticmethod @click.command( "auto-approve", - help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in acceptEdits mode (Claude Code).", + help="Set this as hook at PreToolUse to auto-approve Serena tool calls while the client is in a " + "permissive permission mode (acceptEdits or auto, Claude Code).", ) @_client_option def auto_approve(client: str) -> None: diff --git a/test/serena/test_hooks.py b/test/serena/test_hooks.py index 24af5523..bf7ca82c 100644 --- a/test/serena/test_hooks.py +++ b/test/serena/test_hooks.py @@ -409,7 +409,7 @@ class TestToolUseCounter: class TestPreToolUseAutoApproveSerenaHook: - """Tests for the auto-approve hook that allows Serena tools while the client is in ``acceptEdits`` mode.""" + """Tests for the auto-approve hook that allows Serena tools while the client is in a permissive permission mode (``acceptEdits`` or ``auto``).""" @staticmethod def _approve_input( @@ -440,6 +440,27 @@ class TestPreToolUseAutoApproveSerenaHook: assert hook_output["permissionDecision"] == "allow" assert "acceptedits" in hook_output["permissionDecisionReason"].lower() + def test_approves_serena_tool_in_auto_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """When the tool is a Serena tool and the mode is ``auto``, an allow decision is emitted. + + Claude Code's ``auto`` mode is the hands-off-execution mode users adopt for autonomous + runs; Serena tool calls should be auto-approved there for the same reason as in + ``acceptEdits`` (Serena's destructive tools would otherwise still prompt per call). + """ + stdin_data = self._approve_input(permission_mode="auto") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + + output = capsys.readouterr().out.strip() + result = json.loads(output) + hook_output = result["hookSpecificOutput"] + assert hook_output["hookEventName"] == "PreToolUse" + assert hook_output["permissionDecision"] == "allow" + # the reason must identify the actual mode that triggered the approval, not just say + # "auto-approved" — the substring " auto " (with surrounding spaces) discriminates the + # mode name from the unrelated "Auto-approved" prefix. + assert " auto " in hook_output["permissionDecisionReason"] + def test_accepts_camel_case_permission_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """The hook also reads the ``permissionMode`` (camelCase) variant of the field.""" stdin_data = self._approve_input(permission_mode_key="permissionMode") @@ -470,6 +491,27 @@ class TestPreToolUseAutoApproveSerenaHook: PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() assert capsys.readouterr().out == "" + def test_stays_silent_in_bypass_permissions_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """``bypassPermissions`` already approves everything upstream in Claude Code, so the + hook deliberately does not emit an explicit allow there — it stays silent. + + This test pins that boundary: only ``acceptEdits`` and ``auto`` are active modes for the + hook; expanding it further requires a deliberate change here. + """ + stdin_data = self._approve_input(permission_mode="bypassPermissions") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + + def test_stays_silent_in_dont_ask_mode(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): + """``dontAsk`` is the user's deny-by-default posture (auto-deny unless allow-listed); + the hook honors that choice and stays silent rather than overriding it. + """ + stdin_data = self._approve_input(permission_mode="dontAsk") + with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)): + PreToolUseAutoApproveSerenaHook(HookClient.CLAUDE_CODE).execute() + assert capsys.readouterr().out == "" + def test_stays_silent_when_permission_mode_missing(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]): """If ``permission_mode`` is missing from the input, the hook stays silent rather than erroring.""" stdin_data = self._approve_input(permission_mode=None) @@ -542,6 +584,23 @@ class TestHookCli: output = json.loads(result.output) assert output["hookSpecificOutput"]["permissionDecision"] == "allow" + def test_auto_approve_command_in_auto_mode(self, tmp_path: Path): + """The ``auto-approve`` CLI command emits an allow for a Serena tool in ``auto`` mode.""" + stdin_json = json.dumps( + { + "session_id": "cli-auto-approve-auto-mode", + "tool_name": "mcp__serena__find_symbol", + "tool_input": {}, + "permission_mode": "auto", + } + ) + runner = CliRunner() + with patch("serena.hooks.serena_home_dir", str(tmp_path)): + result = runner.invoke(hook_commands, ["auto-approve", "--client", "claude-code"], input=stdin_json) + assert result.exit_code == 0 + output = json.loads(result.output) + assert output["hookSpecificOutput"]["permissionDecision"] == "allow" + def test_auto_approve_command_stays_silent_in_default_mode(self, tmp_path: Path): """The ``auto-approve`` CLI command emits nothing when the mode is not ``acceptEdits``.""" stdin_json = json.dumps(