mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 00:16:57 +00:00
Merge branch 'main' into add_news_display
Conflicts: src/serena/tools/jetbrains_plugin_client.py
This commit is contained in:
commit
68d91593ef
23 files changed
+1313
-62
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -15,8 +15,21 @@ 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
|
||||
|
||||
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:
|
||||
* 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)
|
||||
@@ -305,6 +305,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+)",
|
||||
|
||||
+49
-23
@@ -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
|
||||
@@ -24,7 +23,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
|
||||
@@ -189,6 +188,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:
|
||||
@@ -247,8 +249,21 @@ 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
|
||||
tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context]
|
||||
# determined by the
|
||||
# * dashboard availability/opening on launch
|
||||
# * Serena config,
|
||||
# * the context (which is fixed for the session)
|
||||
# * single-project mode reductions (if applicable)
|
||||
# * JetBrains mode
|
||||
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:
|
||||
@@ -292,16 +307,10 @@ 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():
|
||||
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)
|
||||
@@ -393,17 +402,34 @@ 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)
|
||||
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
|
||||
|
||||
# open the dashboard URL in the default web browser
|
||||
webbrowser.open(url)
|
||||
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:
|
||||
raise Exception("Dashboard is not running.")
|
||||
|
||||
if not system_has_usable_display():
|
||||
log.warning("Not opening the Serena web dashboard because no usable display was detected.")
|
||||
return False
|
||||
|
||||
# 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:
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
@@ -109,8 +124,16 @@ 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)
|
||||
|
||||
# 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))
|
||||
|
||||
|
||||
@@ -137,6 +160,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 +181,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):
|
||||
@@ -194,8 +233,16 @@ 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)
|
||||
|
||||
# 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
|
||||
|
||||
+18
-5
@@ -11,7 +11,7 @@ from sensai.util import logging
|
||||
|
||||
from serena.analytics import ToolUsageStats
|
||||
from serena.config.serena_config import LanguageBackend, SerenaPaths
|
||||
from serena.constants import REPO_ROOT, SERENA_DASHBOARD_DIR
|
||||
from serena.constants import SERENA_DASHBOARD_DIR
|
||||
from serena.task_executor import TaskExecutor
|
||||
from serena.util.logging import MemoryLogHandler
|
||||
|
||||
@@ -385,12 +385,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
|
||||
@@ -423,11 +426,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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -435,11 +443,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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,17 +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).
|
||||
# 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)
|
||||
|
||||
@@ -2,6 +2,22 @@ from serena.config.context_mode import SerenaAgentMode
|
||||
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def apply(self) -> str:
|
||||
"""
|
||||
Opens the Serena web dashboard in the default web browser.
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Activates a project based on the project name or path.
|
||||
|
||||
@@ -4,6 +4,7 @@ Client for the Serena JetBrains Plugin
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Optional, Self, TypeVar, cast
|
||||
|
||||
@@ -111,10 +112,34 @@ 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/<distro>/... or //wsl$/<distro>/...
|
||||
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:
|
||||
if self._project_root is None:
|
||||
return False
|
||||
return Path(self._project_root).resolve() == resolved_path
|
||||
try:
|
||||
plugin_root = self._normalize_wsl_path(self.project_root())
|
||||
return plugin_root.resolve() == resolved_path
|
||||
except ConnectionError:
|
||||
return False
|
||||
|
||||
def is_version_at_least(self, *version_parts: int) -> bool:
|
||||
if self._plugin_version is None:
|
||||
|
||||
@@ -171,7 +171,9 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp
|
||||
:return: a JSON object containing the symbols grouped by kind in a compact format.
|
||||
"""
|
||||
with JetBrainsPluginClient.from_project(self.project) as client:
|
||||
symbol_overview = client.get_symbols_overview(relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation)
|
||||
symbol_overview = client.get_symbols_overview(
|
||||
relative_path=relative_path, depth=depth, include_file_documentation=include_file_documentation
|
||||
)
|
||||
symbols = symbol_overview["symbols"]
|
||||
result: dict[str, Any] = {"symbols": self._transform_symbols_to_compact_format(symbols)}
|
||||
documentation = symbol_overview.pop("documentation", None)
|
||||
|
||||
@@ -217,6 +217,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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
backup/
|
||||
*.o
|
||||
*.ppu
|
||||
*.exe
|
||||
*.lps
|
||||
*.compiled
|
||||
__history/
|
||||
__recovery/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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]}"
|
||||
@@ -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)")
|
||||
Reference in new issue
Block a user