Merge branch 'main' into add_news_display

Conflicts:
	src/serena/tools/jetbrains_plugin_client.py
This commit is contained in:
Michael Panchenko committed 2026-01-11 20:14:47 +07:00
commit 68d91593ef
23 files changed
+1313 -62

No files matched your search

+53
View File
@@ -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
+3 -1
View File
@@ -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)
+1
View File
@@ -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.
+13
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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:
"""
+59 -12
View File
@@ -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
View File
@@ -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,
}
)
+8 -5
View File
@@ -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)
+16
View File
@@ -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.
+26 -1
View File
@@ -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:
+3 -1
View File
@@ -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
+11
View File
@@ -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
+30 -6
View File
@@ -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.
+15
View File
@@ -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
+195
View File
@@ -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)")