From 511ad0542bb71610b45fe259b361b428a09b6613 Mon Sep 17 00:00:00 2001 From: Tomasz Wyderka Date: Sat, 27 Dec 2025 20:16:57 +0100 Subject: [PATCH 01/13] Add open_dashboard MCP tool for on-demand dashboard opening This provides an alternative for users who want to disable automatic browser opening (web_dashboard_open_on_launch: False) but still have easy access to the dashboard through AI assistance. Changes: - Add OpenDashboardTool in config_tools.py - Add open_dashboard() and get_dashboard_url() methods to SerenaAgent - Store dashboard URL in _dashboard_url field - Update documentation with new tool and configuration option - Update config template comment to mention the tool Closes #875 --- docs/01-about/035_tools.md | 1 + docs/02-usage/060_dashboard.md | 9 ++++++ src/serena/agent.py | 29 +++++++++++++++++++ .../resources/serena_config.template.yml | 3 +- src/serena/tools/config_tools.py | 13 +++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/01-about/035_tools.md b/docs/01-about/035_tools.md index 55ff8bf2..23662750 100644 --- a/docs/01-about/035_tools.md +++ b/docs/01-about/035_tools.md @@ -28,6 +28,7 @@ Note that in most configurations, only a subset of these tools will be enabled s * `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). +* `open_dashboard`: Opens the Serena web dashboard in the default web browser. * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with th e necessary context). * `read_file`: Reads a file within the project directory. diff --git a/docs/02-usage/060_dashboard.md b/docs/02-usage/060_dashboard.md index 73376926..0a7a462a 100644 --- a/docs/02-usage/060_dashboard.md +++ b/docs/02-usage/060_dashboard.md @@ -20,3 +20,12 @@ Serena comes with built-in tools for monitoring and managing the current session Both can be configured in Serena's [configuration](050_configuration) file (`serena_config.yml`). If enabled, they will automatically be opened as soon as the Serena agent/MCP server is started. + +## Disabling Automatic Browser Opening + +If you prefer not to have the dashboard open automatically (e.g., to avoid focus stealing), you can disable it +by setting `web_dashboard_open_on_launch: False` in your `serena_config.yml`. + +When automatic opening is disabled, you can still access the dashboard by: +* Navigating directly to `http://localhost:24282/dashboard/index.html` in your browser +* Asking the AI to use the `open_dashboard` tool, which will open the dashboard in your default browser diff --git a/src/serena/agent.py b/src/serena/agent.py index 02d44079..7f0c0e9c 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -189,6 +189,9 @@ class SerenaAgent: # project-specific instances, which will be initialized upon project activation self._active_project: Project | None = None + # dashboard URL (set when dashboard is started) + self._dashboard_url: str | None = None + # adjust log level serena_log_level = self.serena_config.log_level if Logger.root.level != serena_log_level: @@ -292,6 +295,7 @@ class SerenaAgent: if dashboard_host == "0.0.0.0": dashboard_host = "localhost" dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html" + self._dashboard_url = dashboard_url log.info("Serena web dashboard started at %s", dashboard_url) if self.serena_config.web_dashboard_open_on_launch: if not system_has_usable_display(): @@ -405,6 +409,31 @@ class SerenaAgent: # open the dashboard URL in the default web browser webbrowser.open(url) + def get_dashboard_url(self) -> str | None: + """ + :return: the URL of the web dashboard, or None if the dashboard is not running + """ + return self._dashboard_url + + def open_dashboard(self) -> str: + """ + Opens the Serena web dashboard in the default web browser. + + :return: a message indicating success or failure + """ + if self._dashboard_url is None: + return "Error: The web dashboard is not running. Enable it in the Serena configuration." + + if not system_has_usable_display(): + return f"Error: No usable display detected. You can open the dashboard manually at: {self._dashboard_url}" + + # open the dashboard URL in the default web browser (using a separate process to control + # output redirection) + process = multiprocessing.Process(target=self._open_dashboard, args=(self._dashboard_url,)) + process.start() + process.join(timeout=1) + return f"Dashboard opened in the default web browser: {self._dashboard_url}" + def get_project_root(self) -> str: """ :return: the root directory of the active project (if any); raises a ValueError if there is no active project diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index d197991d..9465961c 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -35,7 +35,8 @@ web_dashboard_listen_address: 127.0.0.1 web_dashboard_open_on_launch: True # whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard # is enabled). If set to False, you can still open the dashboard manually by navigating to -# http://localhost:24282/dashboard/ in your web browser (24282 = 0x5EDA, SErena DAshboard). +# http://localhost:24282/dashboard/ in your web browser (24282 = 0x5EDA, SErena DAshboard), +# or by asking the AI to use the open_dashboard tool. # If you have multiple instances running, a higher port will be used; try port 24283, 24284, etc. log_level: 20 diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index 45ca01c7..265bb2f3 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -2,6 +2,19 @@ from serena.config.context_mode import SerenaAgentMode from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional +class OpenDashboardTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Opens the Serena web dashboard in the default web browser. + The dashboard provides logs, session information, and tool usage statistics. + """ + + def apply(self) -> str: + """ + Opens the Serena web dashboard in the default web browser. + """ + return self.agent.open_dashboard() + + class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): """ Activates a project based on the project name or path. From 0f5d86aadfb09076fd7eb659dd7fa8c79c72a155 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 28 Dec 2025 19:58:38 +0100 Subject: [PATCH 02/13] Improve handling of OpenDashboardTool * Mark the tool as optional (disabled by default) * Enable the tool if the dashboard is enabled, not opened and the GUI is off * Improve factorisation and return value/error handling --- docs/02-usage/060_dashboard.md | 8 ++++++-- src/serena/agent.py | 33 ++++++++++++++++++-------------- src/serena/tools/config_tools.py | 7 +++++-- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/docs/02-usage/060_dashboard.md b/docs/02-usage/060_dashboard.md index 0a7a462a..eeeab1ca 100644 --- a/docs/02-usage/060_dashboard.md +++ b/docs/02-usage/060_dashboard.md @@ -15,11 +15,13 @@ Serena comes with built-in tools for monitoring and managing the current session * the **GUI tool** (disabled by default) The GUI tool is a native application window which displays logs. + It furthermore allows you to shut down the agent and to access the dashboard's URL (if it is running). This is mainly supported on Windows, but it may also work on Linux; macOS is unsupported. Both can be configured in Serena's [configuration](050_configuration) file (`serena_config.yml`). If enabled, they will automatically be opened as soon as the Serena agent/MCP server is started. +For the dashboard, this can be disabled if desired (see below). ## Disabling Automatic Browser Opening @@ -27,5 +29,7 @@ If you prefer not to have the dashboard open automatically (e.g., to avoid focus by setting `web_dashboard_open_on_launch: False` in your `serena_config.yml`. When automatic opening is disabled, you can still access the dashboard by: -* Navigating directly to `http://localhost:24282/dashboard/index.html` in your browser -* Asking the AI to use the `open_dashboard` tool, which will open the dashboard in your default browser +* asking the LLM to "open the Serena dashboard", which will open the dashboard in your default browser + (the tool `open_dashboard` is enabled for this purpose, provided that the dashboard is active, + not opened by default and the GUI tool, which can provide the URL, is not enabled) +* navigating directly to the URL (see above) diff --git a/src/serena/agent.py b/src/serena/agent.py index 7f0c0e9c..a3fe8750 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -24,7 +24,7 @@ from serena.ls_manager import LanguageServerManager from serena.project import Project from serena.prompt_factory import SerenaPromptFactory from serena.task_executor import TaskExecutor -from serena.tools import ActivateProjectTool, GetCurrentConfigTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry +from serena.tools import ActivateProjectTool, GetCurrentConfigTool, OpenDashboardTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry from serena.util.gui import system_has_usable_display from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler @@ -250,8 +250,19 @@ class SerenaAgent: self._check_shell_settings() # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), - # limited by the Serena config, the context (which is fixed for the session) and JetBrains mode + # determined by the + # * Serena config, + # * the context (which is fixed for the session) + # * dashboard availability/opening on launch + # * single-project mode reductions (if applicable) + # * JetBrains mode tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context] + if ( + self.serena_config.web_dashboard + and not self.serena_config.web_dashboard_open_on_launch + and not self.serena_config.gui_log_window_enabled + ): + tool_inclusion_definitions.append(ToolInclusionDefinition(included_optional_tools=[OpenDashboardTool.get_name_from_cls()])) if self._context.single_project: tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project)) if self.serena_config.language_backend == LanguageBackend.JETBRAINS: @@ -298,14 +309,7 @@ class SerenaAgent: self._dashboard_url = dashboard_url log.info("Serena web dashboard started at %s", dashboard_url) if self.serena_config.web_dashboard_open_on_launch: - if not system_has_usable_display(): - log.warning("Not opening the Serena web dashboard automatically because no usable display was detected.") - else: - # open the dashboard URL in the default web browser (using a separate process to control - # output redirection) - process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) - process.start() - process.join(timeout=1) + self.open_dashboard() # inform the GUI window (if any) if self._gui_log_viewer is not None: self._gui_log_viewer.set_dashboard_url(dashboard_url) @@ -415,24 +419,25 @@ class SerenaAgent: """ return self._dashboard_url - def open_dashboard(self) -> str: + def open_dashboard(self) -> bool: """ Opens the Serena web dashboard in the default web browser. :return: a message indicating success or failure """ if self._dashboard_url is None: - return "Error: The web dashboard is not running. Enable it in the Serena configuration." + raise Exception("Dashboard is not running.") if not system_has_usable_display(): - return f"Error: No usable display detected. You can open the dashboard manually at: {self._dashboard_url}" + log.warning("Not opening the Serena web dashboard because no usable display was detected.") + return False # open the dashboard URL in the default web browser (using a separate process to control # output redirection) process = multiprocessing.Process(target=self._open_dashboard, args=(self._dashboard_url,)) process.start() process.join(timeout=1) - return f"Dashboard opened in the default web browser: {self._dashboard_url}" + return True def get_project_root(self) -> str: """ diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index 265bb2f3..7de51e59 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -2,7 +2,7 @@ from serena.config.context_mode import SerenaAgentMode from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional -class OpenDashboardTool(Tool, ToolMarkerDoesNotRequireActiveProject): +class OpenDashboardTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): """ Opens the Serena web dashboard in the default web browser. The dashboard provides logs, session information, and tool usage statistics. @@ -12,7 +12,10 @@ class OpenDashboardTool(Tool, ToolMarkerDoesNotRequireActiveProject): """ Opens the Serena web dashboard in the default web browser. """ - return self.agent.open_dashboard() + if self.agent.open_dashboard(): + return f"Serena web dashboard has been opened in the user's default web browser: {self.agent.get_dashboard_url()}" + else: + return f"Serena web dashboard could not be opened automatically; tell the user to open it via {self.agent.get_dashboard_url()}" class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): From 2138adf4d4ec2d04b8eac961efa34c3b523ed489 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 28 Dec 2025 20:40:53 +0100 Subject: [PATCH 03/13] Use subprocess instead of multiprocessing to open the dashboard (output redirection with multiprocessing worked only when called from the main thread) --- src/serena/agent.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index a3fe8750..2ec82317 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -2,11 +2,10 @@ The Serena Model Context Protocol (MCP) Server """ -import multiprocessing import os import platform +import subprocess import sys -import webbrowser from collections.abc import Callable from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar @@ -401,18 +400,6 @@ class SerenaAgent: log.debug(f"Recording tool usage for tool '{tool_name}'") self._tool_usage_stats.record_tool_usage(tool_name, input_str, output_str) - @staticmethod - def _open_dashboard(url: str) -> None: - # Redirect stdout and stderr file descriptors to /dev/null, - # making sure that nothing can be written to stdout/stderr, even by subprocesses - null_fd = os.open(os.devnull, os.O_WRONLY) - os.dup2(null_fd, sys.stdout.fileno()) - os.dup2(null_fd, sys.stderr.fileno()) - os.close(null_fd) - - # open the dashboard URL in the default web browser - webbrowser.open(url) - def get_dashboard_url(self) -> str | None: """ :return: the URL of the web dashboard, or None if the dashboard is not running @@ -432,11 +419,14 @@ class SerenaAgent: log.warning("Not opening the Serena web dashboard because no usable display was detected.") return False - # open the dashboard URL in the default web browser (using a separate process to control - # output redirection) - process = multiprocessing.Process(target=self._open_dashboard, args=(self._dashboard_url,)) - process.start() - process.join(timeout=1) + # Use a subprocess to avoid any output from webbrowser.open being written to stdout + subprocess.Popen( + [sys.executable, "-c", f"import webbrowser; webbrowser.open({self._dashboard_url!r})"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, # Detach from parent process + ) return True def get_project_root(self) -> str: From 05b93fd4690eded05964b2d15446ab395a5ff6fc Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 28 Dec 2025 20:52:44 +0100 Subject: [PATCH 04/13] Improve comments --- .../resources/serena_config.template.yml | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 9465961c..26158083 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -10,9 +10,8 @@ language_backend: LSP gui_log_window: False # whether to open a graphical window with Serena's logs. # This is mainly supported on Windows and (partly) on Linux; not available on macOS. -# If you want to see the logs in a web browser, use the `web_dashboard` option instead. -# Limitations: doesn't seem to work with the community version of Claude Desktop for Linux -# Might also cause problems with some MCP clients - if you have any issues, try disabling this +# If you prefer a browser-based tool, use the `web_dashboard` option instead. +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html # # Being able to inspect logs is useful both for troubleshooting and for monitoring the tool calls, # especially when using the agno playground, since the tool calls are not always shown, @@ -26,18 +25,21 @@ gui_log_window: False web_dashboard: True # whether to open the Serena web dashboard (which will be accessible through your web browser) that -# shows Serena's current session logs - as an alternative to the GUI log window which -# is supported on all platforms. +# provides access to Serena's configuration and state as well as the current session logs. +# The web dashboard is supported on all platforms. +# Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_listen_address: 127.0.0.1 # The address where the web dashboard will listen on web_dashboard_open_on_launch: True # whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard -# is enabled). If set to False, you can still open the dashboard manually by navigating to -# http://localhost:24282/dashboard/ in your web browser (24282 = 0x5EDA, SErena DAshboard), -# or by asking the AI to use the open_dashboard tool. -# If you have multiple instances running, a higher port will be used; try port 24283, 24284, etc. +# is enabled). +# If set to false, you can still open the dashboard manually 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 log_level: 20 # the minimum log level for the GUI log window and the dashboard (10 = debug, 20 = info, 30 = warning, 40 = error) From a8c720e3b47470d9334cefd9c83f63888ba5308b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 28 Dec 2025 21:10:44 +0100 Subject: [PATCH 05/13] Reorder tool inclusion definitions for improved flexibility (allowing, in particular, open_dashboard to be disabled by SerenaConfig) --- src/serena/agent.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 2ec82317..ae39e8cd 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -250,18 +250,20 @@ class SerenaAgent: # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), # determined by the + # * dashboard availability/opening on launch # * Serena config, # * the context (which is fixed for the session) - # * dashboard availability/opening on launch # * single-project mode reductions (if applicable) # * JetBrains mode - tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context] + tool_inclusion_definitions: list[ToolInclusionDefinition] = [] if ( self.serena_config.web_dashboard and not self.serena_config.web_dashboard_open_on_launch and not self.serena_config.gui_log_window_enabled ): tool_inclusion_definitions.append(ToolInclusionDefinition(included_optional_tools=[OpenDashboardTool.get_name_from_cls()])) + tool_inclusion_definitions.append(self.serena_config) + tool_inclusion_definitions.append(self._context) if self._context.single_project: tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project)) if self.serena_config.language_backend == LanguageBackend.JETBRAINS: From 870aa2cc3a654ec43736106c099aa8a18868aff2 Mon Sep 17 00:00:00 2001 From: Alberto Simoes <104087721+cx-alberto-simoes@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:21:42 +0000 Subject: [PATCH 06/13] Support --mode and --context with yaml files --- src/serena/config/context_mode.py | 51 +++++++++++++++++++++++++------ src/serena/dashboard.py | 21 ++++++++++--- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index f1f34fd9..205e9141 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -41,6 +41,11 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): It is formatted by the agent (see SerenaAgent._format_prompt()). """ description: str = "" + _yaml_path: Path | None = field(default=None, repr=False, compare=False) + """ + Internal field storing the path to the YAML file this mode was loaded from. + Used to support loading modes from arbitrary file paths. + """ def _tostring_includes(self) -> list[str]: return ["name"] @@ -54,14 +59,24 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def from_yaml(cls, yaml_path: str | Path) -> Self: """Load a mode from a YAML file.""" - with open(yaml_path, encoding=SERENA_FILE_ENCODING) as f: + yaml_as_path = Path(yaml_path).resolve() + with Path(yaml_as_path).open(encoding=SERENA_FILE_ENCODING) as f: data = yaml.safe_load(f) - name = data.pop("name", Path(yaml_path).stem) - return cls(name=name, **data) + name = data.pop("name", yaml_as_path.stem) + return cls(name=name, _yaml_path=yaml_as_path, **data) @classmethod - def get_path(cls, name: str) -> str: - """Get the path to the YAML file for a mode.""" + def get_path(cls, name: str, instance: Self | None = None) -> str: + """Get the path to the YAML file for a mode. + + :param name: The name of the mode + :param instance: Optional mode instance. If provided and it has a stored path, that path is returned. + :return: The path to the mode's YAML file + """ + # If we have an instance with a stored path, use that + if instance is not None and instance._yaml_path is not None: + return str(instance._yaml_path) + fname = f"{name}.yml" custom_mode_path = os.path.join(SerenaPaths().user_modes_dir, fname) if os.path.exists(custom_mode_path): @@ -137,6 +152,12 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): maps tool names to custom descriptions, default descriptions are extracted from the tool docstrings. """ + _yaml_path: Path | None = field(default=None, repr=False, compare=False) + """ + Internal field storing the path to the YAML file this context was loaded from. + Used to support loading contexts from arbitrary file paths. + """ + single_project: bool = False """ whether to assume that Serena shall only work on a single project in this context (provided that a project is given @@ -152,17 +173,27 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def from_yaml(cls, yaml_path: str | Path) -> Self: """Load a context from a YAML file.""" - with open(yaml_path, encoding=SERENA_FILE_ENCODING) as f: + yaml_as_path = Path(yaml_path).resolve() + with yaml_as_path.open(encoding=SERENA_FILE_ENCODING) as f: data = yaml.safe_load(f) - name = data.pop("name", Path(yaml_path).stem) + name = data.pop("name", yaml_as_path.stem) # Ensure backwards compatibility for tool_description_overrides if "tool_description_overrides" not in data: data["tool_description_overrides"] = {} - return cls(name=name, **data) + return cls(name=name, _yaml_path=yaml_as_path, **data) @classmethod - def get_path(cls, name: str) -> str: - """Get the path to the YAML file for a context.""" + def get_path(cls, name: str, instance: Self | None = None) -> str: + """Get the path to the YAML file for a context. + + :param name: The name of the context + :param instance: Optional context instance. If provided and it has a stored path, that path is returned. + :return: The path to the context's YAML file + """ + # If we have an instance with a stored path, use that + if instance is not None and instance._yaml_path is not None: + return str(instance._yaml_path) + fname = f"{name}.yml" custom_context_path = os.path.join(SerenaPaths().user_contexts_dir, fname) if os.path.exists(custom_context_path): diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 55d8b87b..1d937b73 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -354,12 +354,15 @@ class SerenaDashboardAPI: context_info = { "name": context.name, "description": context.description, - "path": SerenaAgentContext.get_path(context.name), + "path": SerenaAgentContext.get_path(context.name, instance=context), } # Get active modes modes = self._agent.get_active_modes() - modes_info = [{"name": mode.name, "description": mode.description, "path": SerenaAgentMode.get_path(mode.name)} for mode in modes] + modes_info = [ + {"name": mode.name, "description": mode.description, "path": SerenaAgentMode.get_path(mode.name, instance=mode)} + for mode in modes + ] active_mode_names = [mode.name for mode in modes] # Get active tools @@ -392,11 +395,16 @@ class SerenaDashboardAPI: all_mode_names = SerenaAgentMode.list_registered_mode_names() available_modes: list[dict[str, str | bool]] = [] for mode_name in all_mode_names: + try: + mode_path = SerenaAgentMode.get_path(mode_name) + except FileNotFoundError: + # Skip modes that can't be found (shouldn't happen for registered modes) + continue available_modes.append( { "name": mode_name, "is_active": mode_name in active_mode_names, - "path": SerenaAgentMode.get_path(mode_name), + "path": mode_path, } ) @@ -404,11 +412,16 @@ class SerenaDashboardAPI: all_context_names = SerenaAgentContext.list_registered_context_names() available_contexts: list[dict[str, str | bool]] = [] for context_name in all_context_names: + try: + context_path = SerenaAgentContext.get_path(context_name) + except FileNotFoundError: + # Skip contexts that can't be found (shouldn't happen for registered contexts) + continue available_contexts.append( { "name": context_name, "is_active": context_name == context.name, - "path": SerenaAgentContext.get_path(context_name), + "path": context_path, } ) From 201db5afcbc8f5984eab3249f063ed8e3c9ef58c Mon Sep 17 00:00:00 2001 From: Alberto Simoes <104087721+cx-alberto-simoes@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:00:00 +0000 Subject: [PATCH 07/13] Instead of checking for extension, check if file exists --- src/serena/config/context_mode.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 205e9141..83298ab4 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -124,7 +124,9 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def load(cls, name_or_path: str | Path) -> Self: - if str(name_or_path).endswith(".yml"): + # Check if it's a file path that exists + path = Path(name_or_path) + if path.exists() and path.is_file(): return cls.from_yaml(name_or_path) return cls.from_name(str(name_or_path)) @@ -225,7 +227,9 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def load(cls, name_or_path: str | Path) -> Self: - if str(name_or_path).endswith(".yml"): + # Check if it's a file path that exists + path = Path(name_or_path) + if path.exists() and path.is_file(): return cls.from_yaml(name_or_path) return cls.from_name(str(name_or_path)) From c25fb662629d67a505dc41fb4c263628880e8ecb Mon Sep 17 00:00:00 2001 From: Alberto Simoes <104087721+cx-alberto-simoes@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:02:22 +0000 Subject: [PATCH 08/13] Instead of checking for extension, check if file exists --- src/serena/config/context_mode.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 83298ab4..80528108 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -128,6 +128,16 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): path = Path(name_or_path) if path.exists() and path.is_file(): return cls.from_yaml(name_or_path) + + # If it looks like a file path but doesn't exist, raise FileNotFoundError + name_or_path_str = str(name_or_path) + if ( + os.sep in name_or_path_str + or (os.altsep and os.altsep in name_or_path_str) + or name_or_path_str.endswith((".yml", ".yaml")) + ): + raise FileNotFoundError(f"Mode file not found: {path.resolve()}") + return cls.from_name(str(name_or_path)) @@ -231,6 +241,16 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): path = Path(name_or_path) if path.exists() and path.is_file(): return cls.from_yaml(name_or_path) + + # If it looks like a file path but doesn't exist, raise FileNotFoundError + name_or_path_str = str(name_or_path) + if ( + os.sep in name_or_path_str + or (os.altsep and os.altsep in name_or_path_str) + or name_or_path_str.endswith((".yml", ".yaml")) + ): + raise FileNotFoundError(f"Context file not found: {path.resolve()}") + return cls.from_name(str(name_or_path)) @classmethod From ff68b53397336b598c24fdbffc85a442e1f44ff1 Mon Sep 17 00:00:00 2001 From: Alberto Simoes <104087721+cx-alberto-simoes@users.noreply.github.com> Date: Fri, 9 Jan 2026 09:35:01 +0000 Subject: [PATCH 09/13] Fix context and mode file detection logic - Replace .yml extension check with proper file existence check - Check if path exists as a file first before treating as name - Raise FileNotFoundError for paths that look like files but don't exist (contains path separators or ends with .yml/.yaml) - Improves error messages when file paths are invalid --- src/serena/config/context_mode.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 80528108..9238d806 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -131,11 +131,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): # If it looks like a file path but doesn't exist, raise FileNotFoundError name_or_path_str = str(name_or_path) - if ( - os.sep in name_or_path_str - or (os.altsep and os.altsep in name_or_path_str) - or name_or_path_str.endswith((".yml", ".yaml")) - ): + if os.sep in name_or_path_str or (os.altsep and os.altsep in name_or_path_str) or name_or_path_str.endswith((".yml", ".yaml")): raise FileNotFoundError(f"Mode file not found: {path.resolve()}") return cls.from_name(str(name_or_path)) @@ -244,11 +240,7 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): # If it looks like a file path but doesn't exist, raise FileNotFoundError name_or_path_str = str(name_or_path) - if ( - os.sep in name_or_path_str - or (os.altsep and os.altsep in name_or_path_str) - or name_or_path_str.endswith((".yml", ".yaml")) - ): + if os.sep in name_or_path_str or (os.altsep and os.altsep in name_or_path_str) or name_or_path_str.endswith((".yml", ".yaml")): raise FileNotFoundError(f"Context file not found: {path.resolve()}") return cls.from_name(str(name_or_path)) From 77af789fc3bcd937e32a896ce4479aaf392d092f Mon Sep 17 00:00:00 2001 From: Erik Cervin-Edin Date: Fri, 9 Jan 2026 10:12:26 +0000 Subject: [PATCH 10/13] fix(java): add missing linux-arm64 configuration for Eclipse JDTLS The linux-arm64 platform entry was missing required fields (jre_home_path, jre_path, lombok_jar_path, jdtls_launcher_jar_path, jdtls_readonly_config_path) causing a KeyError when activating Java projects on ARM64 Linux systems. Fixes #486 Co-Authored-By: Claude Opus 4.5 --- src/solidlsp/language_servers/eclipse_jdtls.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index d058521e..1287b6da 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -215,6 +215,11 @@ class EclipseJDTLS(SolidLanguageServer): "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", "archiveType": "zip", "relative_extraction_path": "vscode-java", + "jre_home_path": "extension/jre/21.0.7-linux-aarch64", + "jre_path": "extension/jre/21.0.7-linux-aarch64/bin/java", + "lombok_jar_path": "extension/lombok/lombok-1.18.36.jar", + "jdtls_launcher_jar_path": "extension/server/plugins/org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar", + "jdtls_readonly_config_path": "extension/server/config_linux_arm", }, "linux-x64": { "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", From fa012910cbd464a07bd1ee0d87f20b3165c030d0 Mon Sep 17 00:00:00 2001 From: zen010101 <60574100+zen010101@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:00:23 +0800 Subject: [PATCH 11/13] feat(pascal): add Pascal language server support Add support for Free Pascal/Lazarus projects using pasls. Features: - Auto-download pasls binary for Linux, macOS, and Windows - Platform detection for x64 and arm64 architectures - Support for FPC environment variables (PP, FPCDIR) CI: Add FPC compiler installation (fpc-ootb for Windows) Includes test repository and unit tests. --- .github/workflows/pytest.yml | 53 +++ docs/01-about/020_programming-languages.md | 4 +- pyproject.toml | 1 + src/serena/resources/project.template.yml | 13 +- .../language_servers/pascal_server.py | 322 ++++++++++++++++++ src/solidlsp/ls_config.py | 11 + .../repos/pascal/test_repo/.gitignore | 8 + .../repos/pascal/test_repo/lib/helper.pas | 54 +++ .../resources/repos/pascal/test_repo/main.pas | 130 +++++++ test/solidlsp/pascal/__init__.py | 15 + test/solidlsp/pascal/test_pascal_basic.py | 195 +++++++++++ 11 files changed, 800 insertions(+), 6 deletions(-) create mode 100644 src/solidlsp/language_servers/pascal_server.py create mode 100644 test/resources/repos/pascal/test_repo/.gitignore create mode 100644 test/resources/repos/pascal/test_repo/lib/helper.pas create mode 100644 test/resources/repos/pascal/test_repo/main.pas create mode 100644 test/solidlsp/pascal/__init__.py create mode 100644 test/solidlsp/pascal/test_pascal_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 622b6c4b..0d654c52 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -367,6 +367,59 @@ jobs: mv regal.exe "$HOME/bin/" echo "$HOME/bin" >> $GITHUB_PATH fi + - name: Install Free Pascal Compiler + shell: bash + run: | + if [[ "${{ runner.os }}" == "Linux" ]]; then + sudo apt-get update + sudo apt-get install -y fpc fpc-source + # Set environment variables for pasls + echo "PP=/usr/bin/fpc" >> $GITHUB_ENV + # Find FPC source directory (version may vary) + FPCDIR=$(ls -d /usr/share/fpcsrc/*/ 2>/dev/null | head -1 || echo "/usr/share/fpcsrc") + echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install fpc + # Download FPC source from SourceForge (fpc-src-laz cask is incompatible with ARM64) + FPC_VERSION="3.2.2" + curl -L -o fpc-source.tar.gz "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.tar.gz/download" + mkdir -p "$HOME/fpcsrc" + tar -xzf fpc-source.tar.gz -C "$HOME/fpcsrc" + rm fpc-source.tar.gz + # Set environment variables for pasls + echo "PP=$(which fpc)" >> $GITHUB_ENV + echo "FPCDIR=$HOME/fpcsrc/fpc-${FPC_VERSION}" >> $GITHUB_ENV + elif [[ "${{ runner.os }}" == "Windows" ]]; then + FPC_VERSION="3.2.2" + # Download freepascal-ootb (includes FPC compiler) + curl -L -o fpc-ootb.zip https://github.com/fredvs/freepascal-ootb/releases/download/${FPC_VERSION}/fpc-ootb-322-x86_64-win64.zip + mkdir -p "$HOME/fpc" + unzip -q fpc-ootb.zip -d "$HOME/fpc" + rm fpc-ootb.zip + # Download FPC source from SourceForge (fpc-ootb only has compiled units, not source) + curl -L -o fpc-source.zip "https://sourceforge.net/projects/freepascal/files/Source/${FPC_VERSION}/fpc-${FPC_VERSION}.source.zip/download" + mkdir -p "$HOME/fpcsrc" + unzip -q fpc-source.zip -d "$HOME/fpcsrc" + rm fpc-source.zip + # Find fpc executable (fpc-ootb uses fpc-ootb.exe as the compiler) + echo "=== FPC directory structure ===" + find "$HOME/fpc" -name "*.exe" -type f 2>/dev/null | head -10 + FPC_EXE=$(find "$HOME/fpc" -name "fpc-ootb-64.exe" -type f 2>/dev/null | head -1) + echo "Found FPC executable: $FPC_EXE" + echo "Found FPC source dir: $HOME/fpcsrc/fpc-${FPC_VERSION}" + # Set environment variables for pasls + echo "PP=$FPC_EXE" >> $GITHUB_ENV + echo "FPCDIR=$HOME/fpcsrc/fpc-${FPC_VERSION}" >> $GITHUB_ENV + # Add FPC bin directory to PATH + FPC_BIN_DIR=$(dirname "$FPC_EXE") + echo "$FPC_BIN_DIR" >> $GITHUB_PATH + fi + - name: Verify FPC installation + shell: bash + run: | + echo "PP=$PP" + echo "FPCDIR=$FPCDIR" + fpc -v || echo "FPC not in PATH, using PP directly" - name: Cache language servers id: cache-language-servers uses: actions/cache@v3 diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index a748505e..b2f1e58b 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -60,7 +60,9 @@ Some languages require additional installations or setup steps, as noted. (must be explicitly specified via `--language markdown` when generating project config, primarily useful for documentation-heavy projects) * **Nix** (requires nixd installation) -* **Perl** +* **Pascal** + (Free Pascal/Lazarus; automatically downloads pasls binary; set PP and FPCDIR environment variables for source navigation) +* **Perl** (requires installation of Perl::LanguageServer) * **PHP** (uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features) diff --git a/pyproject.toml b/pyproject.toml index 53bb740d..375bbda5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -304,6 +304,7 @@ markers = [ "haskell: Haskell language server tests", "yaml: language server running for YAML", "powershell: language server running for PowerShell", + "pascal: language server running for Pascal (Free Pascal/Lazarus)", "slow: tests that require additional Expert instances and have long startup times (~60-90s each)", "toml: language server running for TOML", "matlab: language server running for MATLAB (requires MATLAB R2021b+)", diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 362dc7f4..3ae11f41 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -1,15 +1,18 @@ # list of languages for which language servers are started; choose from: # al bash clojure cpp csharp csharp_omnisharp -# dart elixir elm erlang fortran go -# haskell java julia kotlin lua markdown -# nix perl php python python_jedi r -# rego ruby ruby_solargraph rust scala swift -# terraform typescript typescript_vts yaml zig +# dart elixir elm erlang fortran fsharp +# go groovy haskell java julia kotlin +# lua markdown nix pascal perl php +# powershell python python_jedi r rego ruby +# ruby_solargraph rust scala swift terraform toml +# typescript typescript_vts yaml zig # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Free Pascal / Lazarus, use pascal # Special requirements: # - csharp: Requires the presence of a .sln file in the project folder. +# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. # 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. diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py new file mode 100644 index 00000000..bb1c9e13 --- /dev/null +++ b/src/solidlsp/language_servers/pascal_server.py @@ -0,0 +1,322 @@ +""" +Provides Pascal/Free Pascal specific instantiation of the LanguageServer class using pasls. +Contains various configurations and settings specific to Pascal and Free Pascal. + +pasls installation strategy: +1. Use existing pasls from PATH +2. Download prebuilt binary from GitHub releases + +Supported platforms for binary download: +- linux-x64, linux-arm64 +- osx-x64, osx-arm64 +- win-x64 + +You can pass the following entries in ls_specific_settings["pascal"]: +- (reserved for future use) +""" + +import logging +import os +import pathlib +import shutil +import threading + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, quote_windows_path +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class PascalLanguageServer(SolidLanguageServer): + """ + Provides Pascal specific instantiation of the LanguageServer class using pasls. + Contains various configurations and settings specific to Free Pascal and Lazarus. + """ + + PASLS_VERSION = "0.1.0" + PASLS_RELEASES_URL = "https://github.com/zen010101/pascal-language-server/releases/download" + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a PascalLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + pasls_executable_path = self._setup_runtime_dependencies(solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=pasls_executable_path, cwd=repository_root_path), + "pascal", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.completions_available_event = threading.Event() + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for Pascal Language Server (pasls). + + Returns: + str: The command to start the pasls server + + """ + # Check if pasls is already in PATH + pasls_in_path = shutil.which("pasls") + if pasls_in_path: + log.info(f"Found pasls in PATH: {pasls_in_path}") + return quote_windows_path(pasls_in_path) + + # Use RuntimeDependencyCollection for download + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for Linux (x64)", + url=f"{cls.PASLS_RELEASES_URL}/v{cls.PASLS_VERSION}/pasls-linux-x64.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name="pasls", + ), + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for Linux (arm64)", + url=f"{cls.PASLS_RELEASES_URL}/v{cls.PASLS_VERSION}/pasls-linux-arm64.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name="pasls", + ), + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for macOS (x64)", + url=f"{cls.PASLS_RELEASES_URL}/v{cls.PASLS_VERSION}/pasls-darwin-x64.tar.gz", + platform_id="osx-x64", + archive_type="gztar", + binary_name="pasls", + ), + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for macOS (arm64)", + url=f"{cls.PASLS_RELEASES_URL}/v{cls.PASLS_VERSION}/pasls-darwin-arm64.tar.gz", + platform_id="osx-arm64", + archive_type="gztar", + binary_name="pasls", + ), + RuntimeDependency( + id="PascalLanguageServer", + description="Pascal Language Server for Windows (x64)", + url=f"{cls.PASLS_RELEASES_URL}/v{cls.PASLS_VERSION}/pasls-win32-x64.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="pasls.exe", + ), + ] + ) + + pasls_dir = cls.ls_resources_dir(solidlsp_settings) + pasls_executable_path = deps.binary_path(pasls_dir) + + if not os.path.exists(pasls_executable_path): + log.info(f"Downloading pasls to {pasls_dir}...") + deps.install(pasls_dir) + + assert os.path.exists(pasls_executable_path), f"pasls executable not found at {pasls_executable_path}" + os.chmod(pasls_executable_path, 0o755) + log.info(f"Using pasls at: {pasls_executable_path}") + + return quote_windows_path(pasls_executable_path) + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Pascal Language Server. + + pasls (genericptr/pascal-language-server) reads compiler paths from: + 1. Environment variables (PP, FPCDIR, LAZARUSDIR) via TCodeToolsOptions.InitWithEnvironmentVariables + 2. Lazarus config files via GuessCodeToolConfig + + We only pass target OS/CPU in initializationOptions if explicitly set. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + + # Build initializationOptions from environment variables + # pasls reads these to configure CodeTools: + # - PP: Path to FPC compiler executable + # - FPCDIR: Path to FPC source directory + # - LAZARUSDIR: Path to Lazarus directory (only needed for LCL projects) + # - FPCTARGET: Target OS + # - FPCTARGETCPU: Target CPU + initialization_options: dict = {} + + env_vars = ["PP", "FPCDIR", "LAZARUSDIR", "FPCTARGET", "FPCTARGETCPU"] + for var in env_vars: + value = os.environ.get(var, "") + if value: + initialization_options[var] = value + + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": True, + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceEdit": { + "documentChanges": True, + }, + }, + }, + "initializationOptions": initialization_options, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + + return initialize_params # type: ignore + + def _start_server(self) -> None: + """ + Starts the Pascal Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + + def register_capability_handler(params: dict) -> None: + log.debug(f"Capability registered: {params}") + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + # Mark server as ready when we see initialization messages + message_text = msg.get("message", "") + if "initialized" in message_text.lower() or "ready" in message_text.lower(): + log.info("Pascal language server ready signal detected") + self.server_ready.set() + self.completions_available.set() + + def publish_diagnostics(params: dict) -> None: + log.debug(f"Diagnostics: {params}") + return + + def do_nothing(params: dict) -> None: + return + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("window/showMessage", window_log_message) + self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics) + self.server.on_notification("$/progress", do_nothing) + + log.info("Starting Pascal server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Pascal server: {init_response}") + + # Verify capabilities + capabilities = init_response.get("capabilities", {}) + assert "textDocumentSync" in capabilities + + # Check for various capabilities + if "completionProvider" in capabilities: + log.info("Pascal server supports code completion") + if "definitionProvider" in capabilities: + log.info("Pascal server supports go to definition") + if "referencesProvider" in capabilities: + log.info("Pascal server supports find references") + if "documentSymbolProvider" in capabilities: + log.info("Pascal server supports document symbols") + + self.server.notify.initialized({}) + + # Wait for server readiness with timeout + log.info("Waiting for Pascal language server to be ready...") + if not self.server_ready.wait(timeout=5.0): + # pasls may not send explicit ready signals, so we proceed after timeout + log.info("Timeout waiting for Pascal server ready signal, assuming server is ready") + self.server_ready.set() + self.completions_available.set() + else: + log.info("Pascal server initialization complete") + + def is_ignored_dirname(self, dirname: str) -> bool: + """ + Check if a directory should be ignored for Pascal projects. + Common Pascal/Lazarus directories to ignore. + """ + ignored_dirs = { + "lib", + "backup", + "__history", + "__recovery", + "bin", + ".git", + ".svn", + ".hg", + "node_modules", + } + return dirname.lower() in ignored_dirs diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 7489b4f9..04627009 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -64,6 +64,11 @@ class Language(str, Enum): GROOVY = "groovy" VUE = "vue" POWERSHELL = "powershell" + PASCAL = "pascal" + """Pascal Language Server (pasls) for Free Pascal and Lazarus projects. + Automatically downloads pasls binary. Requires FPC for full functionality. + Set PP and FPCDIR environment variables for source navigation. + """ MATLAB = "matlab" """MATLAB language server using the official MathWorks MATLAB Language Server. Requires MATLAB R2021b or later and Node.js. @@ -228,6 +233,8 @@ class Language(str, Enum): return FilenameMatcher(*path_patterns) case self.POWERSHELL: return FilenameMatcher("*.ps1", "*.psm1", "*.psd1") + case self.PASCAL: + return FilenameMatcher("*.pas", "*.pp", "*.lpr", "*.dpr", "*.dpk", "*.inc") case self.GROOVY: return FilenameMatcher("*.groovy", "*.gvy") case self.MATLAB: @@ -393,6 +400,10 @@ class Language(str, Enum): from solidlsp.language_servers.powershell_language_server import PowerShellLanguageServer return PowerShellLanguageServer + case self.PASCAL: + from solidlsp.language_servers.pascal_server import PascalLanguageServer + + return PascalLanguageServer case self.GROOVY: from solidlsp.language_servers.groovy_language_server import GroovyLanguageServer diff --git a/test/resources/repos/pascal/test_repo/.gitignore b/test/resources/repos/pascal/test_repo/.gitignore new file mode 100644 index 00000000..42e0384a --- /dev/null +++ b/test/resources/repos/pascal/test_repo/.gitignore @@ -0,0 +1,8 @@ +backup/ +*.o +*.ppu +*.exe +*.lps +*.compiled +__history/ +__recovery/ diff --git a/test/resources/repos/pascal/test_repo/lib/helper.pas b/test/resources/repos/pascal/test_repo/lib/helper.pas new file mode 100644 index 00000000..71c772bf --- /dev/null +++ b/test/resources/repos/pascal/test_repo/lib/helper.pas @@ -0,0 +1,54 @@ +unit Helper; + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils; + +type + { THelper - A helper class with utility functions } + THelper = class + public + class function FormatString(const Template: string; const Args: array of const): string; + class function IsEven(Number: Integer): Boolean; + end; + +{ Standalone helper functions } +function GetHelperMessage: string; +function MultiplyNumbers(A, B: Integer): Integer; +procedure LogMessage(const Msg: string); + +implementation + +{ THelper } + +class function THelper.FormatString(const Template: string; const Args: array of const): string; +begin + Result := Format(Template, Args); +end; + +class function THelper.IsEven(Number: Integer): Boolean; +begin + Result := (Number mod 2) = 0; +end; + +{ Standalone functions } + +function GetHelperMessage: string; +begin + Result := 'Hello from Helper unit!'; +end; + +function MultiplyNumbers(A, B: Integer): Integer; +begin + Result := A * B; +end; + +procedure LogMessage(const Msg: string); +begin + WriteLn('[LOG] ', Msg); +end; + +end. diff --git a/test/resources/repos/pascal/test_repo/main.pas b/test/resources/repos/pascal/test_repo/main.pas new file mode 100644 index 00000000..f240bee2 --- /dev/null +++ b/test/resources/repos/pascal/test_repo/main.pas @@ -0,0 +1,130 @@ +unit Main; + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, Helper; + +type + { TUser - A simple user class } + TUser = class + private + FName: string; + FAge: Integer; + public + constructor Create(const AName: string; AAge: Integer); + destructor Destroy; override; + + function GetInfo: string; + procedure UpdateAge(NewAge: Integer); + + property Name: string read FName write FName; + property Age: Integer read FAge write FAge; + end; + + { TUserManager - Manages multiple users } + TUserManager = class + private + FUsers: TList; + public + constructor Create; + destructor Destroy; override; + + procedure AddUser(User: TUser); + function GetUserCount: Integer; + function FindUserByName(const AName: string): TUser; + end; + +{ Helper functions } + +/// Calculates the sum of two integers. +/// @param A First integer value +/// @param B Second integer value +/// @returns The sum of A and B +function CalculateSum(A, B: Integer): Integer; +procedure PrintMessage(const Msg: string); + +implementation + +{ TUser implementation } + +constructor TUser.Create(const AName: string; AAge: Integer); +begin + inherited Create; + FName := AName; + FAge := AAge; +end; + +destructor TUser.Destroy; +begin + inherited Destroy; +end; + +function TUser.GetInfo: string; +begin + Result := Format('Name: %s, Age: %d', [FName, FAge]); +end; + +procedure TUser.UpdateAge(NewAge: Integer); +begin + FAge := NewAge; +end; + +{ TUserManager implementation } + +constructor TUserManager.Create; +begin + inherited Create; + FUsers := TList.Create; +end; + +destructor TUserManager.Destroy; +var + i: Integer; +begin + for i := 0 to FUsers.Count - 1 do + TUser(FUsers[i]).Free; + FUsers.Free; + inherited Destroy; +end; + +procedure TUserManager.AddUser(User: TUser); +begin + FUsers.Add(User); +end; + +function TUserManager.GetUserCount: Integer; +begin + Result := FUsers.Count; +end; + +function TUserManager.FindUserByName(const AName: string): TUser; +var + i: Integer; +begin + Result := nil; + for i := 0 to FUsers.Count - 1 do + begin + if TUser(FUsers[i]).Name = AName then + begin + Result := TUser(FUsers[i]); + Exit; + end; + end; +end; + +{ Helper functions } + +function CalculateSum(A, B: Integer): Integer; +begin + Result := A + B; +end; + +procedure PrintMessage(const Msg: string); +begin + WriteLn(Msg); +end; + +end. diff --git a/test/solidlsp/pascal/__init__.py b/test/solidlsp/pascal/__init__.py new file mode 100644 index 00000000..b9d1f3df --- /dev/null +++ b/test/solidlsp/pascal/__init__.py @@ -0,0 +1,15 @@ +def _check_pascal_available() -> bool: + """Check if Pascal language server (pasls) is available. + + Note: pasls will be auto-downloaded if not present, so Pascal + support is always available. + """ + return True + + +PASCAL_AVAILABLE = _check_pascal_available() + + +def is_pascal_available() -> bool: + """Return True if Pascal language server can be used.""" + return PASCAL_AVAILABLE diff --git a/test/solidlsp/pascal/test_pascal_basic.py b/test/solidlsp/pascal/test_pascal_basic.py new file mode 100644 index 00000000..d926207a --- /dev/null +++ b/test/solidlsp/pascal/test_pascal_basic.py @@ -0,0 +1,195 @@ +""" +Basic integration tests for the Pascal language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the Pascal test repository. + +Uses genericptr/pascal-language-server which returns SymbolInformation[] format: +- Returns classes, structs, enums, typedefs, functions/procedures +- Uses correct SymbolKind values: Class=5, Function=12, Method=6, Struct=23 +- Method names don't include parent class prefix; uses containerName instead +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_types import SymbolKind +from test.conftest import language_tests_enabled + +pytestmark = [ + pytest.mark.pascal, + pytest.mark.skipif(not language_tests_enabled(Language.PASCAL), reason="Pascal tests are disabled (pasls/fpc not available)"), +] + + +@pytest.mark.pascal +class TestPascalLanguageServerBasics: + """Test basic functionality of the Pascal language server.""" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_language_server_initialization(self, language_server: SolidLanguageServer) -> None: + """Test that Pascal language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.PASCAL + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for Pascal files. + + genericptr pasls returns proper SymbolKind values: + - Standalone functions: kind=12 (Function) + - Classes: kind=5 (Class) + """ + # Test getting symbols from main.pas + all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + + # Should have symbols + assert len(all_symbols) > 0, "Should have symbols in main.pas" + + # Should detect standalone functions (SymbolKind.Function = 12) + function_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Function] + function_names = [s["name"] for s in function_symbols] + + assert "CalculateSum" in function_names, "Should find CalculateSum function" + assert "PrintMessage" in function_names, "Should find PrintMessage procedure" + + # Should detect classes (SymbolKind.Class = 5) + class_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Class] + class_names = [s["name"] for s in class_symbols] + + assert "TUser" in class_names, "Should find TUser class" + assert "TUserManager" in class_names, "Should find TUserManager class" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_class_methods(self, language_server: SolidLanguageServer) -> None: + """Test detection of class methods in Pascal files. + + pasls returns class methods with SymbolKind.Method (kind 6), not Function (kind 12). + """ + all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + + # Get all method symbols (pasls returns class methods as SymbolKind.Method = 6) + method_symbols = [s for s in all_symbols if s.get("kind") == SymbolKind.Method] + method_names = [s["name"] for s in method_symbols] + + # Should detect TUser methods + expected_tuser_methods = ["Create", "Destroy", "GetInfo", "UpdateAge"] + for method in expected_tuser_methods: + found = method in method_names + assert found, f"Should find method '{method}'" + + # Should detect TUserManager methods + expected_manager_methods = ["Create", "Destroy", "AddUser", "GetUserCount", "FindUserByName"] + for method in expected_manager_methods: + found = method in method_names + assert found, f"Should find method '{method}'" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_helper_unit_symbols(self, language_server: SolidLanguageServer) -> None: + """Test function detection in Helper unit.""" + # Test with lib/helper.pas + helper_all_symbols, _helper_root_symbols = language_server.request_document_symbols("lib/helper.pas").get_all_symbols_and_roots() + + # Should have symbols + assert len(helper_all_symbols) > 0, "Helper unit should have symbols" + + # Extract function symbols + function_symbols = [s for s in helper_all_symbols if s.get("kind") == SymbolKind.Function] + function_names = [s["name"] for s in function_symbols] + + # Should detect standalone functions + expected_functions = ["GetHelperMessage", "MultiplyNumbers", "LogMessage"] + for func_name in expected_functions: + assert func_name in function_names, f"Should find {func_name} function in Helper unit" + + # Should also detect THelper class methods (returned as SymbolKind.Method = 6) + method_symbols = [s for s in helper_all_symbols if s.get("kind") == SymbolKind.Method] + method_names = [s["name"] for s in method_symbols] + assert "FormatString" in method_names, "Should find FormatString method" + assert "IsEven" in method_names, "Should find IsEven method" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_cross_file_references(self, language_server: SolidLanguageServer) -> None: + """Test that Pascal LSP can handle cross-file references.""" + # main.pas uses Helper unit + main_symbols, _main_roots = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + helper_symbols, _helper_roots = language_server.request_document_symbols("lib/helper.pas").get_all_symbols_and_roots() + + # Verify both files have symbols + assert len(main_symbols) > 0, "main.pas should have symbols" + assert len(helper_symbols) > 0, "helper.pas should have symbols" + + # Verify GetHelperMessage is in Helper unit + helper_function_names = [s["name"] for s in helper_symbols if s.get("kind") == SymbolKind.Function] + assert "GetHelperMessage" in helper_function_names, "Helper unit should export GetHelperMessage" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_symbol_locations(self, language_server: SolidLanguageServer) -> None: + """Test that symbols have correct location information. + + Note: genericptr pasls returns the interface declaration location (line ~41), + not the implementation location (line ~115). + """ + all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + + # Find CalculateSum function + calc_symbols = [s for s in all_symbols if s.get("name") == "CalculateSum"] + assert len(calc_symbols) > 0, "Should find CalculateSum" + + calc_symbol = calc_symbols[0] + + # Verify it has location information (SymbolInformation format uses location.range) + if "location" in calc_symbol: + location = calc_symbol["location"] + assert "range" in location, "Location should have range" + assert "start" in location["range"], "Range should have start" + assert "line" in location["range"]["start"], "Start should have line" + line = location["range"]["start"]["line"] + else: + # DocumentSymbol format uses range directly + assert "range" in calc_symbol, "Symbol should have range" + assert "start" in calc_symbol["range"], "Range should have start" + line = calc_symbol["range"]["start"]["line"] + + # CalculateSum is declared at line 41 in main.pas (0-indexed would be 40) + # genericptr pasls returns interface declaration location + assert 35 <= line <= 45, f"CalculateSum should be around line 41 (interface), got {line}" + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_namespace_symbol(self, language_server: SolidLanguageServer) -> None: + """Test that genericptr pasls returns Interface namespace symbol.""" + all_symbols, _root_symbols = language_server.request_document_symbols("main.pas").get_all_symbols_and_roots() + + # genericptr pasls adds an "Interface" namespace symbol + symbol_names = [s["name"] for s in all_symbols] + + # The Interface section should be represented + # Note: This depends on pasls configuration + assert len(all_symbols) > 0, "Should have symbols" + # Interface namespace may or may not be present depending on pasls configuration + _ = symbol_names # used for potential future assertions + + @pytest.mark.parametrize("language_server", [Language.PASCAL], indirect=True) + def test_pascal_hover_with_doc_comments(self, language_server: SolidLanguageServer) -> None: + """Test that hover returns documentation comments. + + CalculateSum has /// style doc comments that should appear in hover. + """ + # CalculateSum is declared at line 46 (1-indexed), so line 45 (0-indexed) + hover = language_server.request_hover("main.pas", 45, 12) + + assert hover is not None, "Hover should return a result" + + # Extract hover content - handle both dict and object formats + if isinstance(hover, dict): + contents = hover.get("contents", {}) + value = contents.get("value", "") if isinstance(contents, dict) else str(contents) + else: + value = hover.contents.value if hasattr(hover.contents, "value") else str(hover.contents) + + # Should contain the function signature + assert "CalculateSum" in value, f"Hover should show function name. Got: {value[:500]}" + + # Should contain the doc comment + assert "Calculates the sum" in value, f"Hover should include doc comment. Got: {value[:500]}" From a8cc4711b63a037dfc275f0fc3b084b3363ef88e Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 10 Jan 2026 14:42:44 +0200 Subject: [PATCH 12/13] fix: add WSL path normalization for JetBrains plugin matching (#916) * fix: add WSL path normalization for JetBrains plugin matching When running Serena MCP server inside WSL2 with JetBrains IDE on Windows, the plugin returns paths like `//wsl.localhost/Ubuntu-24.04/home/user/...` while Serena expects `/home/user/...`. This causes `ServerNotFoundError` because the paths don't match despite pointing to the same location. This commit adds `_normalize_wsl_path()` method that converts WSL UNC paths to standard Linux paths before comparison, enabling seamless integration between WSL-based Claude Code and Windows-based JetBrains IDEs. Fixes #912 --- src/serena/tools/jetbrains_plugin_client.py | 24 ++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 1695e4db..f38c5709 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -4,6 +4,7 @@ Client for the Serena JetBrains Plugin import json import logging +import re from pathlib import Path from typing import Any, Optional, Self, TypeVar @@ -74,9 +75,30 @@ class JetBrainsPluginClient(ToStringMixin): raise ServerNotFoundError("Found no Serena service in a JetBrains IDE instance for the project at " + str(resolved_path)) + @staticmethod + def _normalize_wsl_path(path_str: str) -> Path: + """ + Normalize WSL UNC paths to Linux paths for comparison. + + When JetBrains IDE runs on Windows with a project opened from WSL, + it returns paths like `//wsl.localhost/Ubuntu-24.04/home/user/project` + or `//wsl$/Ubuntu/home/user/project`. This method converts such paths + to standard Linux format `/home/user/project` for proper matching. + + :param path_str: Path string that may be a WSL UNC path + :return: Normalized Path object + """ + path_str = str(path_str) + # Match //wsl.localhost//... or //wsl$//... + match = re.match(r"^//wsl(?:\.localhost|\$)/[^/]+(.*)$", path_str, re.IGNORECASE) + if match: + return Path(match.group(1)) + return Path(path_str) + def matches(self, resolved_path: Path) -> bool: try: - return Path(self.project_root()).resolve() == resolved_path + plugin_root = self._normalize_wsl_path(self.project_root()) + return plugin_root.resolve() == resolved_path except ConnectionError: return False From 502d3f3e6016457fc06d298b1a9225f4de74597c Mon Sep 17 00:00:00 2001 From: Ketema Harris Date: Fri, 9 Jan 2026 02:06:26 -0500 Subject: [PATCH 13/13] fix(lsp): Omit params field for shutdown/exit methods (#870) WHY: - HLS (Haskell) and rust-analyzer use Void/unit types for shutdown/exit - These strict-typed servers reject ANY params field (even empty {}) - PR #851 changed to send params:{} for Delphi/FPC compatibility - This broke HLS/rust-analyzer with "Cannot parse Void" errors EXPECTED: - shutdown/exit methods: omit params field entirely (HLS/rust-analyzer compat) - Other methods with None: send params:{} (Delphi/FPC compat) - Other methods with params: include them unchanged (standard behavior) - Three-way compatibility preserved per JSON-RPC 2.0 spec Refs: oraios/serena#870 --- src/solidlsp/lsp_protocol_handler/server.py | 36 ++- .../test_lsp_protocol_handler_server.py | 282 ++++++++++++++++++ 2 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 test/solidlsp/test_lsp_protocol_handler_server.py diff --git a/src/solidlsp/lsp_protocol_handler/server.py b/src/solidlsp/lsp_protocol_handler/server.py index 3a10e10c..36e24b15 100644 --- a/src/solidlsp/lsp_protocol_handler/server.py +++ b/src/solidlsp/lsp_protocol_handler/server.py @@ -91,16 +91,40 @@ def make_error_response(request_id: Any, err: LSPError) -> StringDict: return {"jsonrpc": "2.0", "id": request_id, "error": err.to_lsp()} +# LSP methods that expect NO params field at all (not even empty object). +# These methods use Void/unit type in their protocol definition. +# - shutdown: HLS uses Haskell's Void type, rust-analyzer expects unit +# - exit: Similar - notification with no params +# Sending params:{} to these methods causes parse errors like "Cannot parse Void" +# See: https://www.jsonrpc.org/specification ("params MAY be omitted") +_NO_PARAMS_METHODS = frozenset({"shutdown", "exit"}) + + +def _build_params_field(method: str, params: PayloadLike) -> StringDict: + """Build the params portion of a JSON-RPC message based on LSP method requirements. + + LSP methods with Void/unit type (shutdown, exit) must omit params field entirely + to satisfy HLS and rust-analyzer. Other methods send empty {} for None params + to maintain Delphi/FPC LSP compatibility (PR #851). + + Returns a dict that can be merged into the message using ** unpacking. + """ + if method in _NO_PARAMS_METHODS: + return {} # Omit params entirely for Void-type methods + elif params is not None: + return {"params": params} + else: + return {"params": {}} # Keep {} for Delphi/FPC compatibility + + def make_notification(method: str, params: PayloadLike) -> StringDict: - # JSON-RPC 2.0: params must be object or array if present, cannot be null - # Some language servers require params to be present, so we send empty object instead of omitting - return {"jsonrpc": "2.0", "method": method, "params": params if params is not None else {}} + """Create a JSON-RPC 2.0 notification message.""" + return {"jsonrpc": "2.0", "method": method, **_build_params_field(method, params)} def make_request(method: str, request_id: Any, params: PayloadLike) -> StringDict: - # JSON-RPC 2.0: params must be object or array if present, cannot be null - # Some language servers require params to be present, so we send empty object instead of omitting - return {"jsonrpc": "2.0", "method": method, "id": request_id, "params": params if params is not None else {}} + """Create a JSON-RPC 2.0 request message.""" + return {"jsonrpc": "2.0", "method": method, "id": request_id, **_build_params_field(method, params)} class StopLoopException(Exception): diff --git a/test/solidlsp/test_lsp_protocol_handler_server.py b/test/solidlsp/test_lsp_protocol_handler_server.py new file mode 100644 index 00000000..f8ce02cf --- /dev/null +++ b/test/solidlsp/test_lsp_protocol_handler_server.py @@ -0,0 +1,282 @@ +""" +Tests for JSON-RPC 2.0 params field handling in LSP protocol. + +These tests verify the correct handling of the params field in LSP requests and notifications, +specifically ensuring: +- Void-type methods (shutdown, exit) omit params field entirely +- Methods with explicit params include them unchanged +- Methods with None params receive params: {} for Delphi/FPC compatibility + +Reference: JSON-RPC 2.0 spec - params field is optional but must be object/array when present. +""" + +from typing import Any + +import pytest + +from solidlsp.lsp_protocol_handler.server import make_notification, make_request + +# ============================================================================= +# Shared Assertion Helpers (DRY extraction per AI Panel recommendation) +# ============================================================================= + + +def assert_jsonrpc_structure( + result: dict[str, Any], + expected_method: str, + expected_keys: set[str], + *, + expected_id: Any | None = None, +) -> None: + """Verify JSON-RPC 2.0 structural requirements with 5-point error messages. + + Args: + result: The dict returned by make_request/make_notification + expected_method: The method name that should be in the result + expected_keys: Exact set of keys that should be present + expected_id: If provided, verify the id field matches (for requests) + + """ + # Verify jsonrpc field + assert "jsonrpc" in result, ( + f"STRUCTURE ERROR: Missing required 'jsonrpc' field.\n" + f"Expected: jsonrpc='2.0'\n" + f"Actual keys: {list(result.keys())}\n" + f"GUIDANCE: All JSON-RPC 2.0 messages must include jsonrpc field." + ) + assert result["jsonrpc"] == "2.0", ( + f"STRUCTURE ERROR: Invalid jsonrpc version.\n" + f"Expected: '2.0'\n" + f"Actual: {result['jsonrpc']!r}\n" + f"GUIDANCE: JSON-RPC 2.0 requires jsonrpc='2.0' exactly." + ) + + # Verify method field + assert "method" in result, ( + f"STRUCTURE ERROR: Missing required 'method' field.\n" + f"Expected: method='{expected_method}'\n" + f"Actual keys: {list(result.keys())}\n" + f"GUIDANCE: All requests/notifications must include method field." + ) + assert result["method"] == expected_method, ( + f"STRUCTURE ERROR: Method mismatch.\n" + f"Expected: '{expected_method}'\n" + f"Actual: {result['method']!r}\n" + f"GUIDANCE: Method field must match the requested method name." + ) + + # Verify id field if expected (requests only) + if expected_id is not None: + assert "id" in result, ( + f"STRUCTURE ERROR: Missing required 'id' field for request.\n" + f"Expected: id={expected_id!r}\n" + f"Actual keys: {list(result.keys())}\n" + f"GUIDANCE: JSON-RPC 2.0 requests must include id field." + ) + assert result["id"] == expected_id, ( + f"STRUCTURE ERROR: Request ID mismatch.\n" + f"Expected: {expected_id!r}\n" + f"Actual: {result['id']!r}\n" + f"GUIDANCE: Request ID must be preserved exactly as provided." + ) + + # Verify exact key set + actual_keys = set(result.keys()) + if actual_keys != expected_keys: + extra = sorted(actual_keys - expected_keys) + missing = sorted(expected_keys - actual_keys) + pytest.fail( + f"STRUCTURE ERROR: Key set mismatch for method '{expected_method}'.\n" + f"Expected keys: {sorted(expected_keys)}\n" + f"Actual keys: {sorted(actual_keys)}\n" + f"Extra keys: {extra}\n" + f"Missing keys: {missing}\n" + f"GUIDANCE: Verify key construction logic for Void-type vs normal methods." + ) + + +def assert_params_omitted(result: dict[str, Any], method: str, req_id: str, input_params: Any = None) -> None: + """Assert that params field is NOT present (for Void-type methods). + + Args: + result: The dict returned by make_request/make_notification + method: Method name for error message context + req_id: Requirement ID (e.g., 'REQ-1', 'REQ-AI-PANEL-GAP') + input_params: If provided, shows what params were passed (for explicit params tests) + + """ + if "params" in result: + input_note = f"\nInput params: {input_params}" if input_params is not None else "" + pytest.fail( + f"{req_id} VIOLATED: {method} method MUST omit params field entirely.{input_note}\n" + f"Expected: No 'params' key in result\n" + f"Actual: params={result.get('params')!r}\n" + f"Actual keys: {list(result.keys())}\n" + f"REASON: HLS/rust-analyzer Void types reject any params field (even empty object).\n" + f"GUIDANCE: Void-type constraint takes precedence - implementation must omit params entirely." + ) + + +def assert_params_equal(result: dict[str, Any], expected_params: Any, req_id: str) -> None: + """Assert that params field equals expected value. + + Args: + result: The dict returned by make_request/make_notification + expected_params: The exact params value expected + req_id: Requirement ID for error message context + + """ + if "params" not in result: + pytest.fail( + f"{req_id} VIOLATED: params field missing.\n" + f"Expected: params={expected_params!r}\n" + f"Actual keys: {list(result.keys())}\n" + f"GUIDANCE: Non-Void methods must include params field." + ) + if result["params"] != expected_params: + pytest.fail( + f"{req_id} VIOLATED: params value mismatch.\n" + f"Expected: {expected_params!r}\n" + f"Actual: {result['params']!r}\n" + f"GUIDANCE: Params must be included exactly as provided (or {{}} for None)." + ) + + +class TestMakeNotificationParamsHandling: + """Test make_notification() params field handling per JSON-RPC 2.0 spec.""" + + def test_shutdown_method_omits_params_entirely(self) -> None: + """REQ-1: Void-type method 'shutdown' MUST omit params field entirely.""" + result = make_notification("shutdown", None) + assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method"}) + assert_params_omitted(result, "shutdown", "REQ-1") + + def test_exit_method_omits_params_entirely(self) -> None: + """REQ-1: Void-type method 'exit' MUST omit params field entirely.""" + result = make_notification("exit", None) + assert_jsonrpc_structure(result, "exit", {"jsonrpc", "method"}) + assert_params_omitted(result, "exit", "REQ-1") + + def test_notification_with_explicit_params_dict(self) -> None: + """REQ-2: Methods with explicit params MUST include them unchanged.""" + test_params = {"uri": "file:///test.py", "languageId": "python"} + result = make_notification("textDocument/didOpen", test_params) + assert_jsonrpc_structure(result, "textDocument/didOpen", {"jsonrpc", "method", "params"}) + assert_params_equal(result, test_params, "REQ-2") + + def test_notification_with_explicit_params_list(self) -> None: + """REQ-2: Methods with explicit params (list) MUST include them unchanged.""" + test_params = ["arg1", "arg2", "arg3"] + result = make_notification("custom/method", test_params) + assert_jsonrpc_structure(result, "custom/method", {"jsonrpc", "method", "params"}) + assert_params_equal(result, test_params, "REQ-2") + + def test_notification_with_none_params_sends_empty_dict(self) -> None: + """REQ-3: Methods with None params MUST send params: {} (Delphi/FPC compat).""" + result = make_notification("textDocument/didChange", None) + assert_jsonrpc_structure(result, "textDocument/didChange", {"jsonrpc", "method", "params"}) + assert_params_equal(result, {}, "REQ-3") + + def test_notification_with_empty_dict_params(self) -> None: + """REQ-2: Explicit empty dict params MUST be included unchanged.""" + result = make_notification("custom/notify", {}) + assert_jsonrpc_structure(result, "custom/notify", {"jsonrpc", "method", "params"}) + assert_params_equal(result, {}, "REQ-2") + + +class TestMakeRequestParamsHandling: + """Test make_request() params field handling per JSON-RPC 2.0 spec.""" + + def test_shutdown_request_omits_params_entirely(self) -> None: + """REQ-1: Void-type method 'shutdown' MUST omit params field entirely (requests).""" + result = make_request("shutdown", request_id=1, params=None) + assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method", "id"}, expected_id=1) + assert_params_omitted(result, "shutdown", "REQ-1") + + def test_request_with_explicit_params_dict(self) -> None: + """REQ-2: Requests with explicit params MUST include them unchanged.""" + test_params = {"textDocument": {"uri": "file:///test.py"}, "position": {"line": 10, "character": 5}} + result = make_request("textDocument/hover", request_id=42, params=test_params) + assert_jsonrpc_structure(result, "textDocument/hover", {"jsonrpc", "method", "id", "params"}, expected_id=42) + assert_params_equal(result, test_params, "REQ-2") + + def test_request_with_none_params_sends_empty_dict(self) -> None: + """REQ-3: Requests with None params MUST send params: {} (Delphi/FPC compat).""" + result = make_request("workspace/configuration", request_id=100, params=None) + assert_jsonrpc_structure(result, "workspace/configuration", {"jsonrpc", "method", "id", "params"}, expected_id=100) + assert_params_equal(result, {}, "REQ-3") + + def test_request_id_preservation(self) -> None: + """Verify request_id is correctly included in result (string ID).""" + test_id = "unique-request-123" + result = make_request("custom/request", request_id=test_id, params={"key": "value"}) + assert_jsonrpc_structure(result, "custom/request", {"jsonrpc", "method", "id", "params"}, expected_id=test_id) + + def test_request_with_explicit_params_list(self) -> None: + """REQ-2: Requests with explicit params (list) MUST include them unchanged.""" + test_params = [1, 2, 3] + result = make_request("custom/sum", request_id=99, params=test_params) + assert_jsonrpc_structure(result, "custom/sum", {"jsonrpc", "method", "id", "params"}, expected_id=99) + assert_params_equal(result, test_params, "REQ-2") + + +class TestVoidMethodsExhaustive: + """Test all methods that should be treated as Void-type (no params).""" + + def test_shutdown_request_ignores_explicit_params_dict(self) -> None: + """REQ-AI-PANEL-GAP: shutdown MUST omit params even when caller explicitly provides params.""" + explicit_params = {"key": "value", "another": "param"} + result = make_request("shutdown", request_id=1, params=explicit_params) + assert_jsonrpc_structure(result, "shutdown", {"jsonrpc", "method", "id"}, expected_id=1) + assert_params_omitted(result, "shutdown", "REQ-AI-PANEL-GAP", input_params=explicit_params) + + def test_exit_notification_ignores_explicit_params(self) -> None: + """REQ-AI-PANEL-GAP: exit MUST omit params even when caller explicitly provides params.""" + explicit_params = {"unexpected": "params"} + result = make_notification("exit", explicit_params) + assert_jsonrpc_structure(result, "exit", {"jsonrpc", "method"}) + assert_params_omitted(result, "exit", "REQ-AI-PANEL-GAP", input_params=explicit_params) + + def test_only_shutdown_and_exit_are_void_methods(self) -> None: + """REQ-BOUNDARY: Verify EXACTLY shutdown/exit are Void-type - no more, no less.""" + # Positive verification: shutdown and exit MUST omit params + shutdown_notif = make_notification("shutdown", None) + exit_notif = make_notification("exit", None) + shutdown_req = make_request("shutdown", 1, None) + + assert "params" not in shutdown_notif, "shutdown notification should omit params" + assert "params" not in exit_notif, "exit notification should omit params" + assert "params" not in shutdown_req, "shutdown request should omit params" + + # Negative verification: other methods MUST include params (even when None -> {}) + non_void_methods = [ + "initialize", + "initialized", + "textDocument/didOpen", + "textDocument/didChange", + "textDocument/didClose", + "workspace/didChangeConfiguration", + "workspace/didChangeWatchedFiles", + ] + + for method in non_void_methods: + result_notif = make_notification(method, None) + result_req = make_request(method, 1, None) + + if "params" not in result_notif: + pytest.fail( + f"BOUNDARY VIOLATION: '{method}' notification treated as Void-type.\n" + f"Expected: params field present (should be {{}})\n" + f"Actual keys: {list(result_notif.keys())}\n" + f"GUIDANCE: Only 'shutdown' and 'exit' should omit params field." + ) + assert_params_equal(result_notif, {}, f"REQ-3 ({method} notification)") + + if "params" not in result_req: + pytest.fail( + f"BOUNDARY VIOLATION: '{method}' request treated as Void-type.\n" + f"Expected: params field present (should be {{}})\n" + f"Actual keys: {list(result_req.keys())}\n" + f"GUIDANCE: Only 'shutdown' and 'exit' should omit params field." + ) + assert_params_equal(result_req, {}, f"REQ-3 ({method} request)")