mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 14:16:21 +00:00
Merge branch 'main' into hover_budget
This commit is contained in:
commit
116ffe6287
38 files changed
+2036
-355
No files matched your search
@@ -375,9 +375,15 @@ jobs:
|
||||
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")
|
||||
# Find FPC source directory (version may vary) - remove trailing slash
|
||||
FPCDIR=$(ls -d /usr/share/fpcsrc/*/ 2>/dev/null | head -1 | sed 's:/$::')
|
||||
if [[ -z "$FPCDIR" ]]; then
|
||||
FPCDIR="/usr/share/fpcsrc"
|
||||
fi
|
||||
echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV
|
||||
echo "=== FPC source directory structure ==="
|
||||
ls -la "$FPCDIR" || echo "FPCDIR not found"
|
||||
ls -la "$FPCDIR/rtl" 2>/dev/null || echo "rtl subdirectory not found"
|
||||
elif [[ "${{ runner.os }}" == "macOS" ]]; then
|
||||
brew install fpc
|
||||
# Download FPC source from SourceForge (fpc-src-laz cask is incompatible with ARM64)
|
||||
@@ -386,9 +392,21 @@ jobs:
|
||||
mkdir -p "$HOME/fpcsrc"
|
||||
tar -xzf fpc-source.tar.gz -C "$HOME/fpcsrc"
|
||||
rm fpc-source.tar.gz
|
||||
# Set environment variables for pasls
|
||||
# Check extracted directory structure (might be nested)
|
||||
echo "=== Extracted FPC source structure ==="
|
||||
ls -la "$HOME/fpcsrc"
|
||||
# Find the actual FPC source root (contains rtl, packages, etc.)
|
||||
if [[ -d "$HOME/fpcsrc/fpc-${FPC_VERSION}/rtl" ]]; then
|
||||
FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}"
|
||||
elif [[ -d "$HOME/fpcsrc/fpc-${FPC_VERSION}/fpc-${FPC_VERSION}/rtl" ]]; then
|
||||
FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}/fpc-${FPC_VERSION}"
|
||||
else
|
||||
FPCDIR="$HOME/fpcsrc/fpc-${FPC_VERSION}"
|
||||
fi
|
||||
echo "PP=$(which fpc)" >> $GITHUB_ENV
|
||||
echo "FPCDIR=$HOME/fpcsrc/fpc-${FPC_VERSION}" >> $GITHUB_ENV
|
||||
echo "FPCDIR=$FPCDIR" >> $GITHUB_ENV
|
||||
echo "=== FPC source directory ==="
|
||||
ls -la "$FPCDIR" || echo "FPCDIR not found"
|
||||
elif [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
FPC_VERSION="3.2.2"
|
||||
# Download freepascal-ootb (includes FPC compiler)
|
||||
@@ -417,9 +435,44 @@ jobs:
|
||||
- name: Verify FPC installation
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Environment variables ==="
|
||||
echo "PP=$PP"
|
||||
echo "FPCDIR=$FPCDIR"
|
||||
fpc -v || echo "FPC not in PATH, using PP directly"
|
||||
|
||||
# Create a simple test program
|
||||
if [[ "${{ runner.os }}" == "Windows" ]]; then
|
||||
TEST_PAS="$TEMP/fpc_test.pas"
|
||||
TEST_OUT="$TEMP/fpc_test"
|
||||
else
|
||||
TEST_PAS="/tmp/fpc_test.pas"
|
||||
TEST_OUT="/tmp/fpc_test"
|
||||
fi
|
||||
echo "program fpc_test; begin writeln('FPC works'); end." > "$TEST_PAS"
|
||||
|
||||
# Compile using PP (the compiler we actually use in tests)
|
||||
echo "=== Compiling test program with PP=$PP ==="
|
||||
if [[ -n "$PP" ]]; then
|
||||
"$PP" "$TEST_PAS" -o"$TEST_OUT" 2>&1
|
||||
else
|
||||
echo "ERROR: PP environment variable is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify output binary exists
|
||||
if [[ -f "$TEST_OUT" ]] || [[ -f "${TEST_OUT}.exe" ]]; then
|
||||
echo "FPC compilation test PASSED"
|
||||
else
|
||||
echo "ERROR: FPC compilation failed - no output binary at $TEST_OUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify FPCDIR exists (required for pasls)
|
||||
if [[ -d "$FPCDIR" ]]; then
|
||||
echo "FPCDIR exists: $FPCDIR"
|
||||
else
|
||||
echo "ERROR: FPCDIR does not exist: $FPCDIR"
|
||||
exit 1
|
||||
fi
|
||||
- name: Cache language servers
|
||||
id: cache-language-servers
|
||||
uses: actions/cache@v3
|
||||
|
||||
@@ -17,7 +17,21 @@ especially for users of JetBrains IDEs.
|
||||
**Purchasing the JetBrains Plugin supports the Serena project.**
|
||||
The proceeds from plugin sales allow us to dedicate more resources to further developing and improving Serena.
|
||||
|
||||
## Configuring Serena
|
||||
|
||||
## Advantages of the JetBrains Plugin
|
||||
|
||||
There are multiple features that are only available when using the JetBrains plugin:
|
||||
|
||||
* **External library indexing**: Dependencies and libraries are fully indexed and accessible to Serena
|
||||
* **No additional setup**: No need to download or configure separate language servers
|
||||
* **Enhanced performance**: Faster tool execution thanks to optimized IDE integration
|
||||
* **Multi-language excellence**: First-class support for polyglot projects with multiple languages and frameworks
|
||||
* **Enhanced retrieval capabilities**: The plugin supports additional retrieval tools for type hierarchy information as well as fast and reliable documentation/type signature retrieval
|
||||
|
||||
We are also working on additional features like a `move_symbol` tool and debugging-related capabilities that
|
||||
will be available exclusively through the JetBrains plugin.
|
||||
|
||||
## Configuring Serena to Use the JetBrains Plugin
|
||||
|
||||
After installing the plugin, you need to configure Serena to use it.
|
||||
|
||||
@@ -44,18 +58,20 @@ you will see `Languages:
|
||||
Using JetBrains backend` in the configuration overview.
|
||||
You will also notice that your client will use the JetBrains-specific tools like `jet_brains_find_symbol` and others like it.
|
||||
|
||||
## Workflow
|
||||
|
||||
## Advantages of the JetBrains Plugin
|
||||
Having installed the plugin in your IDE and having configured Serena to use the JetBrains backend,
|
||||
the general workflow is simple:
|
||||
1. Open the project you want to work on in your JetBrains IDE
|
||||
2. Open the project's root folder as a project in Serena (see [Project Creation](project-creation-indexing) and [Project Activation](project-activation))
|
||||
3. Start using Serena tools as usual
|
||||
|
||||
There are multiple features that are only available when using the JetBrains plugin:
|
||||
Note that the folder that is open in your IDE and the project's root folder must match.
|
||||
|
||||
* **External library indexing**: Dependencies and libraries are fully indexed and accessible to Serena
|
||||
* **No additional setup**: No need to download or configure separate language servers
|
||||
* **Enhanced performance**: Faster tool execution thanks to optimized IDE integration
|
||||
* **Multi-language excellence**: First-class support for polyglot projects with multiple languages and frameworks
|
||||
|
||||
We are also working on additional features like a `move_symbol` tool and debugging-related capabilities that
|
||||
will be available exclusively through the JetBrains plugin.
|
||||
:::{tip}
|
||||
If you need to work on multiple projects in the same agent session, create a monorepo folder
|
||||
containing all the projects and open that folder in both Serena and your IDE.
|
||||
:::
|
||||
|
||||
## Usage with Other Editors
|
||||
|
||||
|
||||
@@ -269,13 +269,6 @@ Then make sure to configure the working directory to be the project root.
|
||||
|
||||
## Antigravity
|
||||
|
||||
:::{warning}
|
||||
At the time of writing (12/2025), Antigravity does not seem to work with Serena due to schema validation issues
|
||||
which are beyond our control.
|
||||
The client starts Serena but then crashes with `[internal] marshal message: string field contains invalid UTF-8`.
|
||||
Nevertheless, we provide a configuration that should work once the issue is resolved.
|
||||
:::
|
||||
|
||||
Add this configuration:
|
||||
|
||||
```json
|
||||
@@ -296,6 +289,12 @@ Add this configuration:
|
||||
}
|
||||
```
|
||||
|
||||
You will have to prompt Antigravity's agent to "Activate the current project using serena's activation tool" after starting Antigravity in the project directory (once in the first chat enough, all other chat sessions will continue using the same Serena session).
|
||||
|
||||
|
||||
Unlike VSCode, Antigravity does not currently support including the working directory in the MCP configuration.
|
||||
Also, the current client will be shown as `none` in Serena's dashboard (Antigravity currently does not fully support the MCP specifications). This is not a problem, all tools will work as expected.
|
||||
|
||||
## Other Clients
|
||||
|
||||
For other clients, follow the [general instructions](#clients-general-instructions) above to set up Serena as an MCP server.
|
||||
|
||||
@@ -15,6 +15,8 @@ setting up a project with Serena typically involves the following steps:
|
||||
(project-creation-indexing)=
|
||||
## Project Creation & Indexing
|
||||
|
||||
Project creation is the process of defining fundamental project settings that are relevant to Serena's operation.
|
||||
|
||||
You can create a project either
|
||||
* implicitly, by just activating a directory as a project while already in a conversation; this will use default settings for your project (skip to the next section).
|
||||
* explicitly, using the project creation command, or
|
||||
@@ -72,6 +74,7 @@ While in the project directory, run this command:
|
||||
|
||||
Indexing has to be called only once. During regular usage, Serena will automatically update the index whenever files change.
|
||||
|
||||
(project-activation)=
|
||||
## Project Activation
|
||||
|
||||
Project activation makes Serena aware of the project you want to work with.
|
||||
@@ -89,6 +92,8 @@ You can either choose to do this
|
||||
* when the MCP server starts, by passing the project path or name as a command-line argument
|
||||
(e.g. when using a single-project mode like `ide` or `claude-code`): `--project <path|name>`
|
||||
|
||||
When working with the JetBrains plugin, be sure to have the same project folder open as a project in your IDE,
|
||||
i.e. the folder that is activated in Serena should correspond to the root folder of the project in your IDE.
|
||||
|
||||
## Onboarding & Memories
|
||||
|
||||
|
||||
@@ -215,6 +215,51 @@ Notes:
|
||||
- `GOFLAGS` (from the environment you start Serena in) may also affect the Go build context. Prefer `buildFlags` for tags.
|
||||
- Build context changes are only picked up when `gopls` starts. After changing `gopls_settings` (or relevant env vars like `GOFLAGS`), restart the Serena process (or server) that hosts the Go language server, or use your client's "Restart language server" action if it causes `gopls` to restart.
|
||||
|
||||
#### Pascal (`pasls`)
|
||||
|
||||
Serena uses [pasls](https://github.com/genericptr/pascal-language-server) (Pascal Language Server) for Pascal/Free Pascal support.
|
||||
|
||||
**Language Server Installation:**
|
||||
|
||||
1. If `pasls` is found in your system PATH, Serena uses it directly
|
||||
2. Otherwise, Serena automatically downloads a prebuilt binary from GitHub releases
|
||||
|
||||
Supported platforms for automatic download: Linux (x64, arm64), macOS (x64, arm64), Windows (x64).
|
||||
|
||||
**Auto-Update:**
|
||||
|
||||
Serena automatically checks for pasls updates every 24 hours. Updates include:
|
||||
- SHA256 checksum verification before installation
|
||||
- Atomic update with rollback on failure
|
||||
- Windows file locking detection (defers update if pasls is in use)
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Configure pasls via `ls_specific_settings.pascal` in `serena_config.yml`:
|
||||
|
||||
| Setting | Description |
|
||||
| ---------------- | --------------------------------------------------------------------------- |
|
||||
| `pp` | Path to FPC compiler driver (must be `fpc` or `fpc.exe`, not `ppc386.exe`) |
|
||||
| `fpcdir` | Path to FPC source directory |
|
||||
| `lazarusdir` | Path to Lazarus directory (required for LCL projects) |
|
||||
| `fpc_target` | Target OS override (e.g., `Win32`, `Win64`, `Linux`) |
|
||||
| `fpc_target_cpu` | Target CPU override (e.g., `i386`, `x86_64`, `aarch64`) |
|
||||
|
||||
Example configuration:
|
||||
|
||||
```yaml
|
||||
ls_specific_settings:
|
||||
pascal:
|
||||
pp: "D:/laz32/fpc/bin/i386-win32/fpc.exe"
|
||||
fpcdir: "D:/laz32/fpcsrc"
|
||||
lazarusdir: "D:/laz32/lazarus"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The `pp` setting is the most important for hover and navigation to work correctly.
|
||||
- Use the FPC compiler driver (`fpc`/`fpc.exe`), not backend compilers like `ppc386.exe`.
|
||||
- These settings are passed as environment variables to the pasls process.
|
||||
|
||||
### Custom Prompts
|
||||
|
||||
All of Serena's prompts can be fully customized.
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ text = "MIT"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"black[jupyter]>=23.7.0",
|
||||
"black[jupyter]>=23.7.0, <26", # black 26 is incompatible with our pathspec version
|
||||
"jinja2",
|
||||
# In version 1.0.4 we get a NoneType error related to some config conversion (yml_analytics is None and should be a list)
|
||||
"mypy>=1.16.1",
|
||||
|
||||
@@ -25,7 +25,7 @@ if __name__ == "__main__":
|
||||
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
serena_config.log_level = logging.INFO
|
||||
serena_config.gui_log_window_enabled = False
|
||||
serena_config.gui_log_window = False
|
||||
serena_config.web_dashboard = False
|
||||
|
||||
agent = SerenaAgent(str(project_path), serena_config=serena_config)
|
||||
|
||||
+24
-28
@@ -52,7 +52,11 @@ class AvailableTools:
|
||||
:param tools: the list of available tools
|
||||
"""
|
||||
self.tools = tools
|
||||
self.tool_names = [tool.get_name_from_cls() for tool in tools]
|
||||
self.tool_names = sorted([tool.get_name_from_cls() for tool in tools])
|
||||
"""
|
||||
the list of available tool names, sorted alphabetically
|
||||
"""
|
||||
self._tool_name_set = set(self.tool_names)
|
||||
self.tool_marker_names = set()
|
||||
for marker_class in iter_subclasses(ToolMarker):
|
||||
for tool in tools:
|
||||
@@ -62,6 +66,9 @@ class AvailableTools:
|
||||
def __len__(self) -> int:
|
||||
return len(self.tools)
|
||||
|
||||
def contains_tool_name(self, tool_name: str) -> bool:
|
||||
return tool_name in self._tool_name_set
|
||||
|
||||
|
||||
class ToolSet:
|
||||
"""
|
||||
@@ -209,7 +216,7 @@ class SerenaAgent:
|
||||
|
||||
# open GUI log window if enabled
|
||||
self._gui_log_viewer: Optional["GuiLogViewer"] = None
|
||||
if self.serena_config.gui_log_window_enabled:
|
||||
if self.serena_config.gui_log_window:
|
||||
log.info("Opening GUI window")
|
||||
if platform.system() == "Darwin":
|
||||
log.warning("GUI log window is not supported on macOS")
|
||||
@@ -262,7 +269,7 @@ class SerenaAgent:
|
||||
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
|
||||
and not self.serena_config.gui_log_window
|
||||
):
|
||||
tool_inclusion_definitions.append(ToolInclusionDefinition(included_optional_tools=[OpenDashboardTool.get_name_from_cls()]))
|
||||
tool_inclusion_definitions.append(self.serena_config)
|
||||
@@ -289,7 +296,7 @@ class SerenaAgent:
|
||||
self._modes = modes
|
||||
|
||||
# determine the subset of active tools (depending on active modes and the active project)
|
||||
self._active_tools: dict[type[Tool], Tool] = {}
|
||||
self._active_tools: AvailableTools = self._exposed_tools
|
||||
self._update_active_tools()
|
||||
|
||||
# activate a project configuration (if provided or if there is only a single project available)
|
||||
@@ -491,12 +498,13 @@ class SerenaAgent:
|
||||
return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names)
|
||||
|
||||
def create_system_prompt(self) -> str:
|
||||
available_markers = self._exposed_tools.tool_marker_names
|
||||
log.info("Generating system prompt with available_tools=(see exposed tools), available_markers=%s", available_markers)
|
||||
available_tools = self._active_tools
|
||||
available_markers = available_tools.tool_marker_names
|
||||
log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers)
|
||||
system_prompt = self.prompt_factory.create_system_prompt(
|
||||
context_system_prompt=self._format_prompt(self._context.prompt),
|
||||
mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self._modes],
|
||||
available_tools=self._exposed_tools.tool_names,
|
||||
available_tools=available_tools.tool_names,
|
||||
available_markers=available_markers,
|
||||
)
|
||||
|
||||
@@ -522,13 +530,10 @@ class SerenaAgent:
|
||||
if self._active_project.project_config.read_only:
|
||||
tool_set = tool_set.without_editing_tools()
|
||||
|
||||
self._active_tools = {
|
||||
tool_class: tool_instance
|
||||
for tool_class, tool_instance in self._all_tools.items()
|
||||
if tool_set.includes_name(tool_instance.get_name())
|
||||
}
|
||||
tools = [t for t in self._all_tools.values() if tool_set.includes_name(t.get_name())]
|
||||
self._active_tools = AvailableTools(tools)
|
||||
|
||||
log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}")
|
||||
log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self._active_tools.tool_names)}")
|
||||
|
||||
def issue_task(
|
||||
self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None
|
||||
@@ -617,27 +622,18 @@ class SerenaAgent:
|
||||
self._activate_project(project_instance)
|
||||
return project_instance
|
||||
|
||||
def get_active_tool_classes(self) -> list[type["Tool"]]:
|
||||
"""
|
||||
:return: the list of active tool classes for the current project
|
||||
"""
|
||||
return list(self._active_tools.keys())
|
||||
|
||||
def get_active_tool_names(self) -> list[str]:
|
||||
"""
|
||||
:return: the list of names of the active tools for the current project
|
||||
:return: the list of names of the active tools for the current project, sorted alphabetically
|
||||
"""
|
||||
return sorted([tool.get_name_from_cls() for tool in self.get_active_tool_classes()])
|
||||
return self._active_tools.tool_names
|
||||
|
||||
def tool_is_active(self, tool_class: type["Tool"] | str) -> bool:
|
||||
def tool_is_active(self, tool_name: str) -> bool:
|
||||
"""
|
||||
:param tool_class: the class or name of the tool to check
|
||||
:param tool_class: the name of the tool to check
|
||||
:return: True if the tool is active, False otherwise
|
||||
"""
|
||||
if isinstance(tool_class, str):
|
||||
return tool_class in self.get_active_tool_names()
|
||||
else:
|
||||
return tool_class in self.get_active_tool_classes()
|
||||
return self._active_tools.contains_tool_name(tool_name)
|
||||
|
||||
def get_current_config_overview(self) -> str:
|
||||
"""
|
||||
@@ -726,7 +722,7 @@ class SerenaAgent:
|
||||
return self._all_tools[tool_class] # type: ignore
|
||||
|
||||
def print_tool_overview(self) -> None:
|
||||
ToolRegistry().print_tool_overview(self._active_tools.values())
|
||||
ToolRegistry().print_tool_overview(self._active_tools.tools)
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.shutdown()
|
||||
|
||||
+32
-38
@@ -18,7 +18,7 @@ from tqdm import tqdm
|
||||
|
||||
from serena.agent import SerenaAgent
|
||||
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
|
||||
from serena.config.serena_config import LanguageBackend, ProjectConfig, SerenaConfig, SerenaPaths
|
||||
from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig, SerenaPaths
|
||||
from serena.constants import (
|
||||
DEFAULT_CONTEXT,
|
||||
DEFAULT_MODES,
|
||||
@@ -510,18 +510,19 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> ProjectConfig:
|
||||
def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> RegisteredProject:
|
||||
"""
|
||||
Helper method to create a project configuration file.
|
||||
|
||||
:param project_path: Path to the project directory
|
||||
:param name: Optional project name (defaults to directory name if not specified)
|
||||
:param language: Tuple of language names
|
||||
:return: The generated ProjectConfig instance
|
||||
:raises FileExistsError: If project.yml already exists
|
||||
:raises ValueError: If an unsupported language is specified
|
||||
:return: the RegisteredProject instance
|
||||
"""
|
||||
yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml())
|
||||
project_root = Path(project_path).resolve()
|
||||
yml_path = ProjectConfig.path_to_project_yml(project_root)
|
||||
if os.path.exists(yml_path):
|
||||
raise FileExistsError(f"Project file {yml_path} already exists.")
|
||||
|
||||
@@ -540,7 +541,15 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
yml_path = ProjectConfig.path_to_project_yml(project_path)
|
||||
languages_str = ", ".join([lang.value for lang in generated_conf.languages]) if generated_conf.languages else "N/A"
|
||||
click.echo(f"Generated project with languages {{{languages_str}}} at {yml_path}.")
|
||||
return generated_conf
|
||||
|
||||
# add to SerenaConfig's list of registered projects
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
registered_project = serena_config.get_registered_project(str(project_root))
|
||||
if registered_project is None:
|
||||
registered_project = RegisteredProject(str(project_root), generated_conf)
|
||||
serena_config.add_registered_project(registered_project)
|
||||
|
||||
return registered_project
|
||||
|
||||
@staticmethod
|
||||
@click.command("create", help="Create a new Serena project configuration.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH})
|
||||
@@ -559,10 +568,10 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file (only used if --index is set).")
|
||||
def create(project_path: str, name: str | None, language: tuple[str, ...], index: bool, log_level: str, timeout: float) -> None:
|
||||
try:
|
||||
ProjectCommands._create_project(project_path, name, language)
|
||||
registered_project = ProjectCommands._create_project(project_path, name, language)
|
||||
if index:
|
||||
click.echo("Indexing project...")
|
||||
ProjectCommands._index_project(project_path, log_level, timeout=timeout)
|
||||
ProjectCommands._index_project(registered_project, log_level, timeout=timeout)
|
||||
except FileExistsError as e:
|
||||
raise click.ClickException(f"Project already exists: {e}\nUse 'serena project index' to index an existing project.")
|
||||
except ValueError as e:
|
||||
@@ -574,7 +583,7 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.",
|
||||
context_settings={"max_content_width": _MAX_CONTENT_WIDTH},
|
||||
)
|
||||
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
|
||||
@click.argument("project", type=PROJECT_TYPE, default=os.getcwd(), required=False)
|
||||
@click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).")
|
||||
@click.option(
|
||||
"--language",
|
||||
@@ -590,43 +599,30 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
)
|
||||
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
|
||||
def index(project: str, name: str | None, language: tuple[str, ...], log_level: str, timeout: float) -> None:
|
||||
# Check if project.yml exists, if not auto-create it
|
||||
yml_path = os.path.join(project, ProjectConfig.rel_path_to_project_yml())
|
||||
if not os.path.exists(yml_path):
|
||||
click.echo(f"Project configuration not found at {yml_path}. Auto-creating...")
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
registered_project = serena_config.get_registered_project(project, autoregister=True)
|
||||
if registered_project is None:
|
||||
# Project not found; auto-create it
|
||||
click.echo(f"No existing project found for '{project}'. Attempting auto-creation ...")
|
||||
try:
|
||||
ProjectCommands._create_project(project, name, language)
|
||||
except FileExistsError:
|
||||
# Race condition - file was created between check and creation
|
||||
pass
|
||||
except ValueError as e:
|
||||
registered_project = ProjectCommands._create_project(project, name, language)
|
||||
except Exception as e:
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
ProjectCommands._index_project(project, log_level, timeout=timeout)
|
||||
ProjectCommands._index_project(registered_project, log_level, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
@click.command(
|
||||
"index-deprecated", help="Deprecated alias for 'serena project index'.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}
|
||||
)
|
||||
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
|
||||
@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING")
|
||||
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
|
||||
def index_deprecated(project: str, log_level: str, timeout: float) -> None:
|
||||
click.echo("Deprecated! Use `serena project index` instead.")
|
||||
ProjectCommands._index_project(project, log_level, timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def _index_project(project: str, log_level: str, timeout: float) -> None:
|
||||
def _index_project(registered_project: RegisteredProject, log_level: str, timeout: float) -> None:
|
||||
lvl = logging.getLevelNamesMapping()[log_level.upper()]
|
||||
logging.configure(level=lvl)
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
proj = Project.load(os.path.abspath(project))
|
||||
click.echo(f"Indexing symbols in project {project} …")
|
||||
proj = registered_project.get_project_instance()
|
||||
click.echo(f"Indexing symbols in {proj} …")
|
||||
ls_mgr = proj.create_language_server_manager(
|
||||
log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings
|
||||
)
|
||||
try:
|
||||
log_file = os.path.join(project, ".serena", "logs", "indexing.txt")
|
||||
log_file = os.path.join(proj.project_root, ".serena", "logs", "indexing.txt")
|
||||
|
||||
files = proj.gather_source_files()
|
||||
|
||||
@@ -746,7 +742,7 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
try:
|
||||
# Create SerenaAgent with dashboard disabled
|
||||
log.info("Creating SerenaAgent with disabled dashboard...")
|
||||
config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False)
|
||||
config = SerenaConfig(gui_log_window=False, web_dashboard=False)
|
||||
agent = SerenaAgent(project=project_path, serena_config=config)
|
||||
log.info("SerenaAgent created successfully")
|
||||
|
||||
@@ -779,8 +775,7 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
|
||||
# Test 1: Get symbols overview
|
||||
log.info("Testing GetSymbolsOverviewTool on file: %s", target_file)
|
||||
overview_result = agent.execute_task(lambda: overview_tool.apply(target_file))
|
||||
overview_data = json.loads(overview_result)
|
||||
overview_data = agent.execute_task(lambda: overview_tool.get_symbol_overview(target_file))
|
||||
log.info("GetSymbolsOverviewTool returned %d symbols", len(overview_data))
|
||||
|
||||
if not overview_data:
|
||||
@@ -806,7 +801,7 @@ class ProjectCommands(AutoRegisteringGroup):
|
||||
|
||||
symbol_name = selected_symbol.get("name_path", "unknown")
|
||||
symbol_kind = selected_symbol.get("kind", "unknown")
|
||||
log.info("Using symbol for testing: %s (kind: %d)", symbol_name, symbol_kind)
|
||||
log.info("Using symbol for testing: %s (kind: %s)", symbol_name, symbol_kind)
|
||||
|
||||
# Test 2: FindSymbolTool
|
||||
log.info("Testing FindSymbolTool for symbol: %s", symbol_name)
|
||||
@@ -1025,7 +1020,6 @@ prompts = PromptCommands()
|
||||
# Expose toplevel commands for the same reason
|
||||
top_level = TopLevelCommands()
|
||||
start_mcp_server = top_level.start_mcp_server
|
||||
index_project = project.index_deprecated
|
||||
|
||||
# needed for the help script to work - register all subcommands to the top-level group
|
||||
for subgroup in (mode, context, project, config, tools, prompts):
|
||||
|
||||
@@ -5,7 +5,7 @@ The Serena Model Context Protocol (MCP) Server
|
||||
import dataclasses
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Iterator, Sequence
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -394,6 +394,14 @@ class RegisteredProject(ToStringMixin):
|
||||
project_instance=project_instance,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_project_root(cls, project_root: str | Path) -> "RegisteredProject":
|
||||
project_config = ProjectConfig.load(project_root)
|
||||
return RegisteredProject(
|
||||
project_root=str(project_root),
|
||||
project_config=project_config,
|
||||
)
|
||||
|
||||
def matches_root_path(self, path: str | Path) -> bool:
|
||||
"""
|
||||
Check if the given path matches the project root path.
|
||||
@@ -423,8 +431,10 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
For testing purposes, it can also be instantiated directly with the desired parameters.
|
||||
"""
|
||||
|
||||
# *** fields that are mapped directly to/from the configuration file (DO NOT RENAME) ***
|
||||
|
||||
projects: list[RegisteredProject] = field(default_factory=list)
|
||||
gui_log_window_enabled: bool = False
|
||||
gui_log_window: bool = False
|
||||
log_level: int = logging.INFO
|
||||
trace_lsp_communication: bool = False
|
||||
web_dashboard: bool = True
|
||||
@@ -474,12 +484,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
# *** static members ***
|
||||
|
||||
CONFIG_FILE = "serena_config.yml"
|
||||
CONFIG_FILE_RENAMED_KEYS = {
|
||||
"gui_log_window_enabled": "gui_log_window",
|
||||
}
|
||||
"""
|
||||
maps attributes to the keys used in the configuration file, in case they differ
|
||||
"""
|
||||
CONFIG_FIELDS_WITH_TYPE_CONVERSION = {"projects", "language_backend"}
|
||||
|
||||
# *** methods ***
|
||||
|
||||
@@ -487,6 +492,15 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
def config_file_path(self) -> str | None:
|
||||
return self._config_file_path
|
||||
|
||||
def _iter_config_file_mapped_fields_without_type_conversion(self) -> Iterator[str]:
|
||||
for field_info in dataclasses.fields(self):
|
||||
field_name = field_info.name
|
||||
if field_name.startswith("_"):
|
||||
continue
|
||||
if field_name in self.CONFIG_FIELDS_WITH_TYPE_CONVERSION:
|
||||
continue
|
||||
yield field_name
|
||||
|
||||
def _tostring_includes(self) -> list[str]:
|
||||
return ["config_file_path"]
|
||||
|
||||
@@ -541,14 +555,23 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
|
||||
# create the configuration instance
|
||||
instance = cls(_loaded_commented_yaml=loaded_commented_yaml, _config_file_path=config_file_path)
|
||||
num_migrations = 0
|
||||
|
||||
def get_value_or_default(field_name: str) -> Any:
|
||||
nonlocal num_migrations
|
||||
if field_name not in loaded_commented_yaml:
|
||||
num_migrations += 1
|
||||
return loaded_commented_yaml.get(field_name, get_dataclass_default(SerenaConfig, field_name))
|
||||
|
||||
# transfer regular fields that do not require type conversion
|
||||
for field_name in instance._iter_config_file_mapped_fields_without_type_conversion():
|
||||
assert hasattr(instance, field_name)
|
||||
setattr(instance, field_name, get_value_or_default(field_name))
|
||||
|
||||
# read projects
|
||||
if "projects" not in loaded_commented_yaml:
|
||||
raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.")
|
||||
|
||||
# load list of known projects
|
||||
instance.projects = []
|
||||
num_migrations = 0
|
||||
for path in loaded_commented_yaml["projects"]:
|
||||
path = Path(path).resolve()
|
||||
if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()):
|
||||
@@ -566,13 +589,6 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
)
|
||||
instance.projects.append(project)
|
||||
|
||||
def get_value_or_default(field_name: str) -> Any:
|
||||
key = cls.CONFIG_FILE_RENAMED_KEYS.get(field_name, field_name)
|
||||
nonlocal num_migrations
|
||||
if key not in loaded_commented_yaml:
|
||||
num_migrations += 1
|
||||
return loaded_commented_yaml.get(key, get_dataclass_default(SerenaConfig, field_name))
|
||||
|
||||
# determine language backend
|
||||
language_backend = get_dataclass_default(SerenaConfig, "language_backend")
|
||||
if "language_backend" in loaded_commented_yaml:
|
||||
@@ -587,20 +603,12 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
del loaded_commented_yaml["jetbrains"]
|
||||
instance.language_backend = language_backend
|
||||
|
||||
# set other configuration parameters (primitive types)
|
||||
instance.gui_log_window_enabled = get_value_or_default("gui_log_window_enabled")
|
||||
instance.web_dashboard_listen_address = get_value_or_default("web_dashboard_listen_address")
|
||||
instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO))
|
||||
instance.web_dashboard = get_value_or_default("web_dashboard")
|
||||
instance.web_dashboard_open_on_launch = get_value_or_default("web_dashboard_open_on_launch")
|
||||
instance.jetbrains_plugin_server_address = get_value_or_default("jetbrains_plugin_server_address")
|
||||
instance.tool_timeout = get_value_or_default("tool_timeout")
|
||||
instance.trace_lsp_communication = get_value_or_default("trace_lsp_communication")
|
||||
instance.excluded_tools = get_value_or_default("excluded_tools")
|
||||
instance.included_optional_tools = get_value_or_default("included_optional_tools")
|
||||
instance.token_count_estimator = get_value_or_default("token_count_estimator")
|
||||
instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars")
|
||||
instance.ls_specific_settings = get_value_or_default("ls_specific_settings")
|
||||
# migrate deprecated "gui_log_level" field if necessary
|
||||
if "gui_log_level" in loaded_commented_yaml:
|
||||
num_migrations += 1
|
||||
if "log_level" not in loaded_commented_yaml:
|
||||
instance.log_level = loaded_commented_yaml["gui_log_level"]
|
||||
del loaded_commented_yaml["gui_log_level"]
|
||||
|
||||
# Load and validate include_info_hover_budget_seconds
|
||||
hover_budget_raw = get_value_or_default("include_info_hover_budget_seconds")
|
||||
@@ -651,14 +659,19 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
def project_names(self) -> list[str]:
|
||||
return sorted(project.project_config.project_name for project in self.projects)
|
||||
|
||||
def get_project(self, project_root_or_name: str) -> Optional["Project"]:
|
||||
def get_registered_project(self, project_root_or_name: str, autoregister: bool = False) -> Optional[RegisteredProject]:
|
||||
"""
|
||||
:param project_root_or_name: path to the project root or the name of the project
|
||||
:param autoregister: whether to register the project if it exists but is not registered yet
|
||||
:return: the registered project, or None if not found
|
||||
"""
|
||||
# look for project by name
|
||||
project_candidates = []
|
||||
for project in self.projects:
|
||||
if project.project_config.project_name == project_root_or_name:
|
||||
project_candidates.append(project)
|
||||
if len(project_candidates) == 1:
|
||||
return project_candidates[0].get_project_instance()
|
||||
return project_candidates[0]
|
||||
elif len(project_candidates) > 1:
|
||||
raise ValueError(
|
||||
f"Multiple projects found with name '{project_root_or_name}'. Please activate it by location instead. "
|
||||
@@ -668,13 +681,36 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
if os.path.isdir(project_root_or_name):
|
||||
for project in self.projects:
|
||||
if project.matches_root_path(project_root_or_name):
|
||||
return project.get_project_instance()
|
||||
return project
|
||||
# no registered project found; auto-register if project configuration exists
|
||||
if autoregister:
|
||||
config_path = ProjectConfig.path_to_project_yml(project_root_or_name)
|
||||
if os.path.isfile(config_path):
|
||||
registered_project = RegisteredProject.from_project_root(project_root_or_name)
|
||||
self.add_registered_project(registered_project)
|
||||
return registered_project
|
||||
# nothing found
|
||||
return None
|
||||
|
||||
def get_project(self, project_root_or_name: str) -> Optional["Project"]:
|
||||
registered_project = self.get_registered_project(project_root_or_name)
|
||||
if registered_project is None:
|
||||
return None
|
||||
else:
|
||||
return registered_project.get_project_instance()
|
||||
|
||||
def add_registered_project(self, registered_project: RegisteredProject) -> None:
|
||||
"""
|
||||
Adds a registered project, saving the configuration file.
|
||||
"""
|
||||
self.projects.append(registered_project)
|
||||
self.save()
|
||||
|
||||
def add_project_from_path(self, project_root: Path | str) -> "Project":
|
||||
"""
|
||||
Add a project to the Serena configuration from a given path. Will raise a FileExistsError if a
|
||||
project already exists at the path.
|
||||
Add a new project to the Serena configuration from a given path, auto-generating the project
|
||||
with defaults if it does not exist.
|
||||
Will raise a FileExistsError if a project already exists at the path.
|
||||
|
||||
:param project_root: the path to the project to add
|
||||
:return: the project that was added
|
||||
@@ -696,8 +732,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
project_config = ProjectConfig.load(project_root, autogenerate=True)
|
||||
|
||||
new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True)
|
||||
self.projects.append(RegisteredProject.from_project_instance(new_project))
|
||||
self.save()
|
||||
self.add_registered_project(RegisteredProject.from_project_instance(new_project))
|
||||
|
||||
return new_project
|
||||
|
||||
@@ -720,24 +755,19 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
|
||||
assert self._loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML"
|
||||
|
||||
loaded_original_yaml = deepcopy(self._loaded_commented_yaml)
|
||||
commented_yaml = deepcopy(self._loaded_commented_yaml)
|
||||
|
||||
# update fields with current values
|
||||
# iterate over all dataclass fields
|
||||
for field_info in dataclasses.fields(self):
|
||||
field_name = field_info.name
|
||||
if field_name in ["projects", "language_backend"] or field_name.startswith("_"):
|
||||
continue
|
||||
yaml_key = SerenaConfig.CONFIG_FILE_RENAMED_KEYS.get(field_name, field_name)
|
||||
loaded_original_yaml[yaml_key] = getattr(self, field_name)
|
||||
for field_name in self._iter_config_file_mapped_fields_without_type_conversion():
|
||||
commented_yaml[field_name] = getattr(self, field_name)
|
||||
|
||||
# convert project objects into list of paths
|
||||
loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects})
|
||||
commented_yaml["projects"] = sorted({str(project.project_root) for project in self.projects})
|
||||
|
||||
# convert language backend to string
|
||||
loaded_original_yaml["language_backend"] = self.language_backend.value
|
||||
commented_yaml["language_backend"] = self.language_backend.value
|
||||
|
||||
save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True)
|
||||
save_yaml(self.config_file_path, commented_yaml, preserve_comments=True)
|
||||
|
||||
def propagate_settings(self) -> None:
|
||||
"""
|
||||
|
||||
+1
-1
@@ -305,7 +305,7 @@ class SerenaMCPFactory:
|
||||
if enable_web_dashboard is not None:
|
||||
config.web_dashboard = enable_web_dashboard
|
||||
if enable_gui_log_window is not None:
|
||||
config.gui_log_window_enabled = enable_gui_log_window
|
||||
config.gui_log_window = enable_gui_log_window
|
||||
if open_web_dashboard is not None:
|
||||
config.web_dashboard_open_on_launch = open_web_dashboard
|
||||
if log_level is not None:
|
||||
|
||||
+65
-49
@@ -116,6 +116,18 @@ class Symbol(ToStringMixin, ABC):
|
||||
"""
|
||||
|
||||
|
||||
class NamePathComponent:
|
||||
def __init__(self, name: str, overload_idx: int | None = None) -> None:
|
||||
self.name = name
|
||||
self.overload_idx = overload_idx
|
||||
|
||||
def __repr__(self) -> str:
|
||||
if self.overload_idx is not None:
|
||||
return f"{self.name}[{self.overload_idx}]"
|
||||
else:
|
||||
return self.name
|
||||
|
||||
|
||||
class NamePathMatcher(ToStringMixin):
|
||||
"""
|
||||
Matches name paths of symbols against search patterns.
|
||||
@@ -132,6 +144,29 @@ class NamePathMatcher(ToStringMixin):
|
||||
Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]".
|
||||
"""
|
||||
|
||||
class PatternComponent(NamePathComponent):
|
||||
@classmethod
|
||||
def from_string(cls, component_str: str) -> Self:
|
||||
overload_idx = None
|
||||
if component_str.endswith("]") and "[" in component_str:
|
||||
bracket_idx = component_str.rfind("[")
|
||||
index_part = component_str[bracket_idx + 1 : -1]
|
||||
if index_part.isdigit():
|
||||
component_str = component_str[:bracket_idx]
|
||||
overload_idx = int(index_part)
|
||||
return cls(name=component_str, overload_idx=overload_idx)
|
||||
|
||||
def matches(self, name_path_component: NamePathComponent, substring_matching: bool) -> bool:
|
||||
if substring_matching:
|
||||
if self.name not in name_path_component.name:
|
||||
return False
|
||||
else:
|
||||
if self.name != name_path_component.name:
|
||||
return False
|
||||
if self.overload_idx is not None and self.overload_idx != name_path_component.overload_idx:
|
||||
return False
|
||||
return True
|
||||
|
||||
def __init__(self, name_path_pattern: str, substring_matching: bool) -> None:
|
||||
"""
|
||||
:param name_path_pattern: the name path expression to match against
|
||||
@@ -141,51 +176,32 @@ class NamePathMatcher(ToStringMixin):
|
||||
self._expr = name_path_pattern
|
||||
self._substring_matching = substring_matching
|
||||
self._is_absolute_pattern = name_path_pattern.startswith(NAME_PATH_SEP)
|
||||
self._pattern_parts = name_path_pattern.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP)
|
||||
|
||||
# extract overload index "[idx]" if present at end of last part
|
||||
self._overload_idx: int | None = None
|
||||
last_part = self._pattern_parts[-1]
|
||||
if last_part.endswith("]") and "[" in last_part:
|
||||
bracket_idx = last_part.rfind("[")
|
||||
index_part = last_part[bracket_idx + 1 : -1]
|
||||
if index_part.isdigit():
|
||||
self._pattern_parts[-1] = last_part[:bracket_idx]
|
||||
self._overload_idx = int(index_part)
|
||||
self._components = [
|
||||
self.PatternComponent.from_string(x) for x in name_path_pattern.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP)
|
||||
]
|
||||
|
||||
def _tostring_includes(self) -> list[str]:
|
||||
return ["_expr"]
|
||||
|
||||
def matches_ls_symbol(self, symbol: "LanguageServerSymbol") -> bool:
|
||||
return self.matches_components(symbol.get_name_path_parts(), symbol.overload_idx)
|
||||
return self.matches_reversed_components(symbol.iter_name_path_components_reversed())
|
||||
|
||||
def matches_components(self, symbol_name_path_parts: list[str], overload_idx: int | None) -> bool:
|
||||
# filtering based on ancestors
|
||||
if len(self._pattern_parts) > len(symbol_name_path_parts):
|
||||
# can't possibly match if pattern has more parts than symbol
|
||||
return False
|
||||
if self._is_absolute_pattern and len(self._pattern_parts) != len(symbol_name_path_parts):
|
||||
# for absolute patterns, the number of parts must match exactly
|
||||
return False
|
||||
if symbol_name_path_parts[-len(self._pattern_parts) : -1] != self._pattern_parts[:-1]:
|
||||
# ancestors must match
|
||||
return False
|
||||
|
||||
# matching the last part of the symbol name
|
||||
name_to_match = self._pattern_parts[-1]
|
||||
symbol_name = symbol_name_path_parts[-1]
|
||||
if self._substring_matching:
|
||||
if name_to_match not in symbol_name:
|
||||
def matches_reversed_components(self, components_reversed: Iterator[NamePathComponent]) -> bool:
|
||||
for i, pattern_component in enumerate(reversed(self._components)):
|
||||
try:
|
||||
symbol_component = next(components_reversed)
|
||||
except StopIteration:
|
||||
return False
|
||||
else:
|
||||
if name_to_match != symbol_name:
|
||||
use_substring_matching = self._substring_matching and (i == 0)
|
||||
if not pattern_component.matches(symbol_component, use_substring_matching):
|
||||
return False
|
||||
|
||||
# check for matching overload index
|
||||
if self._overload_idx is not None:
|
||||
if overload_idx != self._overload_idx:
|
||||
if self._is_absolute_pattern:
|
||||
# ensure that there are no more components in the symbol
|
||||
try:
|
||||
next(components_reversed)
|
||||
return False
|
||||
|
||||
except StopIteration:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
@@ -301,25 +317,24 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
|
||||
|
||||
@property
|
||||
def body(self) -> str | None:
|
||||
return self.symbol_root.get("body")
|
||||
body = self.symbol_root.get("body")
|
||||
if body is None:
|
||||
return None
|
||||
else:
|
||||
return body.get_text()
|
||||
|
||||
def get_name_path(self) -> str:
|
||||
"""
|
||||
Get the name path of the symbol, e.g. "class/method/inner_function" or
|
||||
"class/method[1]" (overloaded method with identifying index).
|
||||
"""
|
||||
name_path = NAME_PATH_SEP.join(self.get_name_path_parts())
|
||||
if "overload_idx" in self.symbol_root:
|
||||
name_path += f"[{self.symbol_root['overload_idx']}]"
|
||||
name_path = NAME_PATH_SEP.join(reversed([str(x) for x in self.iter_name_path_components_reversed()]))
|
||||
return name_path
|
||||
|
||||
def get_name_path_parts(self) -> list[str]:
|
||||
"""
|
||||
Get the parts of the name path of the symbol (e.g. ["class", "method", "inner_function"]).
|
||||
"""
|
||||
ancestors_within_file = list(self.iter_ancestors(up_to_symbol_kind=SymbolKind.File))
|
||||
ancestors_within_file.reverse()
|
||||
return [a.name for a in ancestors_within_file] + [self.name]
|
||||
def iter_name_path_components_reversed(self) -> Iterator[NamePathComponent]:
|
||||
yield NamePathComponent(self.name, self.overload_idx)
|
||||
for ancestor in self.iter_ancestors(up_to_symbol_kind=SymbolKind.File):
|
||||
yield NamePathComponent(ancestor.name, ancestor.overload_idx)
|
||||
|
||||
def iter_children(self) -> Iterator[Self]:
|
||||
for c in self.symbol_root["children"]:
|
||||
@@ -419,9 +434,10 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
|
||||
result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line}
|
||||
|
||||
if include_body:
|
||||
if self.body is None:
|
||||
body = self.body
|
||||
if body is None:
|
||||
log.warning("Requested body for symbol, but it is not present. The symbol might have been loaded with include_body=False.")
|
||||
result["body"] = self.body
|
||||
result["body"] = body
|
||||
|
||||
if child_inclusion_predicate is None:
|
||||
child_inclusion_predicate = lambda s: True
|
||||
|
||||
@@ -87,15 +87,16 @@ class JetBrainsPluginClient(ToStringMixin):
|
||||
self._session.headers.update({"Content-Type": "application/json", "Accept": "application/json"})
|
||||
|
||||
# connect and obtain status
|
||||
self._project_root: str | None
|
||||
self._plugin_version: Version | None
|
||||
self._project_root: str | None = None
|
||||
self._plugin_version: Version | None = None
|
||||
try:
|
||||
status_response: PluginStatusDTO = cast(jb.PluginStatusDTO, self._make_request("GET", "/status"))
|
||||
self._project_root = status_response["project_root"]
|
||||
self._plugin_version = Version(status_response["plugin_version"])
|
||||
except ConnectionError:
|
||||
self._project_root = None
|
||||
self._plugin_version = None
|
||||
except ConnectionError: # expected if no server is running at the port
|
||||
pass
|
||||
except Exception as e:
|
||||
log.warning("Failed to obtain status from JetBrains plugin service at port %d: %s", e, port, exc_info=True)
|
||||
|
||||
@property
|
||||
def _base_url(self) -> str:
|
||||
|
||||
@@ -64,20 +64,28 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
|
||||
Don't adjust unless there is really no other way to get the content required for the task.
|
||||
:return: a JSON object containing symbols grouped by kind in a compact format.
|
||||
"""
|
||||
result = self.get_symbol_overview(relative_path, depth=depth)
|
||||
compact_result = self._transform_symbols_to_compact_format(result)
|
||||
result_json_str = self._to_json(compact_result)
|
||||
return self._limit_length(result_json_str, max_answer_chars)
|
||||
|
||||
def get_symbol_overview(self, relative_path: str, depth: int = 0) -> list[dict]:
|
||||
"""
|
||||
:param relative_path: relative path to a source file
|
||||
:param depth: the depth up to which descendants shall be retrieved
|
||||
:return: a list of symbol dictionaries representing the symbol overview of the file
|
||||
"""
|
||||
symbol_retriever = self.create_language_server_symbol_retriever()
|
||||
file_path = os.path.join(self.project.project_root, relative_path)
|
||||
|
||||
# The symbol overview is capable of working with both files and directories,
|
||||
# but we want to ensure that the user provides a file path.
|
||||
file_path = os.path.join(self.project.project_root, relative_path)
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.")
|
||||
if os.path.isdir(file_path):
|
||||
raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ")
|
||||
result = symbol_retriever.get_symbol_overview(relative_path, depth=depth)[relative_path]
|
||||
# Transform to compact format
|
||||
compact_result = self._transform_symbols_to_compact_format(result)
|
||||
result_json_str = self._to_json(compact_result)
|
||||
return self._limit_length(result_json_str, max_answer_chars)
|
||||
|
||||
return symbol_retriever.get_symbol_overview(relative_path, depth=depth)[relative_path]
|
||||
|
||||
@staticmethod
|
||||
def _transform_symbols_to_compact_format(symbols: list[dict[str, Any]]) -> dict[str, list]:
|
||||
|
||||
@@ -14,7 +14,6 @@ from sensai.util.string import dict_string
|
||||
|
||||
from serena.project import MemoriesManager, Project
|
||||
from serena.prompt_factory import PromptFactory
|
||||
from serena.symbol import LanguageServerSymbolRetriever
|
||||
from serena.util.class_decorators import singleton
|
||||
from serena.util.inspection import iter_subclasses
|
||||
from solidlsp.ls_exceptions import SolidLSPException
|
||||
@@ -22,6 +21,7 @@ from solidlsp.ls_exceptions import SolidLSPException
|
||||
if TYPE_CHECKING:
|
||||
from serena.agent import SerenaAgent
|
||||
from serena.code_editor import CodeEditor
|
||||
from serena.symbol import LanguageServerSymbolRetriever
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
T = TypeVar("T")
|
||||
@@ -46,7 +46,9 @@ class Component(ABC):
|
||||
def memories_manager(self) -> "MemoriesManager":
|
||||
return self.project.memories_manager
|
||||
|
||||
def create_language_server_symbol_retriever(self) -> LanguageServerSymbolRetriever:
|
||||
def create_language_server_symbol_retriever(self) -> "LanguageServerSymbolRetriever":
|
||||
from serena.symbol import LanguageServerSymbolRetriever
|
||||
|
||||
if not self.agent.is_using_language_server():
|
||||
raise Exception("Cannot create LanguageServerSymbolRetriever; agent is not in language server mode.")
|
||||
language_server_manager = self.agent.get_language_server_manager_or_raise()
|
||||
@@ -230,7 +232,7 @@ class Tool(Component):
|
||||
return result
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self.agent.tool_is_active(self.__class__)
|
||||
return self.agent.tool_is_active(self.get_name())
|
||||
|
||||
def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import re
|
||||
import stat
|
||||
import time
|
||||
import zipfile
|
||||
@@ -12,8 +13,9 @@ from pathlib import Path
|
||||
import requests
|
||||
from overrides import override
|
||||
|
||||
from solidlsp import ls_types
|
||||
from solidlsp.language_servers.common import quote_windows_path
|
||||
from solidlsp.ls import SolidLanguageServer
|
||||
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
|
||||
from solidlsp.ls_config import LanguageServerConfig
|
||||
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
|
||||
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink
|
||||
@@ -38,6 +40,45 @@ class ALLanguageServer(SolidLanguageServer):
|
||||
- File opening requirement before symbol retrieval
|
||||
"""
|
||||
|
||||
# Regex pattern to match AL object names like:
|
||||
# - 'Table 50000 "TEST Customer"' -> captures 'TEST Customer'
|
||||
# - 'Codeunit 50000 CustomerMgt' -> captures 'CustomerMgt'
|
||||
# - 'Interface IPaymentProcessor' -> captures 'IPaymentProcessor'
|
||||
# - 'Enum 50000 CustomerType' -> captures 'CustomerType'
|
||||
# Pattern: <ObjectType> [<ID>] (<QuotedName>|<UnquotedName>)
|
||||
_AL_OBJECT_NAME_PATTERN = re.compile(
|
||||
r"^(?:Table|Page|Codeunit|Enum|Interface|Report|Query|XMLPort|PermissionSet|"
|
||||
r"PermissionSetExtension|Profile|PageExtension|TableExtension|EnumExtension|"
|
||||
r"PageCustomization|ReportExtension|ControlAddin|DotNetPackage)" # Object type
|
||||
r"(?:\s+\d+)?" # Optional object ID
|
||||
r"\s+" # Required space before name
|
||||
r'(?:"([^"]+)"|(\S+))$' # Quoted name (group 1) or unquoted identifier (group 2)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_al_display_name(full_name: str) -> str:
|
||||
"""
|
||||
Extract the display name from an AL symbol's full name.
|
||||
|
||||
AL Language Server returns symbol names in format:
|
||||
- 'Table 50000 "TEST Customer"' -> 'TEST Customer'
|
||||
- 'Codeunit 50000 CustomerMgt' -> 'CustomerMgt'
|
||||
- 'Interface IPaymentProcessor' -> 'IPaymentProcessor'
|
||||
- 'fields' -> 'fields' (non-AL-object symbols pass through unchanged)
|
||||
|
||||
Args:
|
||||
full_name: The full symbol name as returned by AL Language Server
|
||||
|
||||
Returns:
|
||||
The extracted display name for matching, or the original name if not an AL object
|
||||
|
||||
"""
|
||||
match = ALLanguageServer._AL_OBJECT_NAME_PATTERN.match(full_name)
|
||||
if match:
|
||||
# Return quoted name (group 1) or unquoted name (group 2)
|
||||
return match.group(1) or match.group(2) or full_name
|
||||
return full_name
|
||||
|
||||
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
|
||||
"""
|
||||
Initialize the AL Language Server.
|
||||
@@ -69,6 +110,14 @@ class ALLanguageServer(SolidLanguageServer):
|
||||
|
||||
super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "al", solidlsp_settings)
|
||||
|
||||
# Cache mapping (file_path, line, char) -> original_full_name for hover injection
|
||||
self._al_original_names: dict[tuple[str, int, int], str] = {}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""Normalize file path for consistent cache key usage across platforms."""
|
||||
return path.replace("\\", "/")
|
||||
|
||||
@classmethod
|
||||
def _download_al_extension(cls, url: str, target_dir: str) -> bool:
|
||||
"""
|
||||
@@ -957,3 +1006,78 @@ class ALLanguageServer(SolidLanguageServer):
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to set active workspace: {e}")
|
||||
# Non-critical error, continue operation
|
||||
|
||||
@override
|
||||
def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
|
||||
"""
|
||||
Override to normalize AL symbol names by stripping object type and ID metadata.
|
||||
|
||||
AL Language Server returns symbol names with full object format like
|
||||
'Table 50000 "TEST Customer"', but symbol names should be pure without metadata.
|
||||
This follows the same pattern as Java LS which strips type information from names.
|
||||
|
||||
Metadata (object type, ID) is available via the hover LSP method when using
|
||||
include_info=True in find_symbol.
|
||||
"""
|
||||
# Normalize path separators for cross-platform compatibility (backslash → forward slash)
|
||||
relative_file_path = self._normalize_path(relative_file_path)
|
||||
|
||||
# Get symbols from parent implementation
|
||||
document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer)
|
||||
|
||||
# Normalize names by stripping AL object metadata, storing originals for hover
|
||||
def normalize_name(symbol: UnifiedSymbolInformation) -> None:
|
||||
original_name = symbol["name"]
|
||||
normalized_name = self._extract_al_display_name(original_name)
|
||||
|
||||
# Store original name if it was normalized (for hover injection)
|
||||
# Only store if we have valid position data to avoid false matches at (0, 0)
|
||||
if original_name != normalized_name:
|
||||
sel_range = symbol.get("selectionRange")
|
||||
if sel_range:
|
||||
start = sel_range.get("start")
|
||||
if start and "line" in start and "character" in start:
|
||||
line = start["line"]
|
||||
char = start["character"]
|
||||
self._al_original_names[(relative_file_path, line, char)] = original_name
|
||||
|
||||
symbol["name"] = normalized_name
|
||||
|
||||
# Process children recursively
|
||||
if symbol.get("children"):
|
||||
for child in symbol["children"]:
|
||||
normalize_name(child)
|
||||
|
||||
# Apply to all root symbols
|
||||
for sym in document_symbols.root_symbols:
|
||||
normalize_name(sym)
|
||||
|
||||
return document_symbols
|
||||
|
||||
@override
|
||||
def request_hover(self, relative_file_path: str, line: int, column: int) -> ls_types.Hover | None:
|
||||
"""
|
||||
Override to inject original AL object name (with type and ID) into hover responses.
|
||||
|
||||
When hovering over a symbol whose name was normalized, we prepend the original
|
||||
full name (e.g., 'Table 50000 "TEST Customer"') to the hover content.
|
||||
"""
|
||||
# Normalize path separators for cross-platform compatibility (backslash → forward slash)
|
||||
relative_file_path = self._normalize_path(relative_file_path)
|
||||
|
||||
hover = super().request_hover(relative_file_path, line, column)
|
||||
|
||||
if hover is None:
|
||||
return None
|
||||
|
||||
# Check if we have an original name for this position
|
||||
original_name = self._al_original_names.get((relative_file_path, line, column))
|
||||
|
||||
if original_name and "contents" in hover:
|
||||
contents = hover["contents"]
|
||||
if isinstance(contents, dict) and "value" in contents:
|
||||
# Prepend the original full name to the hover content
|
||||
prefix = f"**{original_name}**\n\n---\n\n"
|
||||
contents["value"] = prefix + contents["value"]
|
||||
|
||||
return hover
|
||||
@@ -191,7 +191,7 @@ class CSharpLanguageServer(SolidLanguageServer):
|
||||
Provides C# specific instantiation of the LanguageServer class using `Microsoft.CodeAnalysis.LanguageServer`,
|
||||
the official Roslyn-based language server from Microsoft.
|
||||
|
||||
You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]. This is a list of
|
||||
You can pass a list of runtime dependency overrides in ls_specific_settings["csharp"]["runtime_dependencies"]. This is a list of
|
||||
dicts, each containing at least the "id" key, and optionally "platform_id" to uniquely identify the dependency to override.
|
||||
For example, to override the URL of the .NET runtime on windows-x64, add the entry:
|
||||
|
||||
|
||||
@@ -4,22 +4,58 @@ 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
|
||||
2. Download prebuilt binary from GitHub releases (auto-updated)
|
||||
|
||||
Supported platforms for binary download:
|
||||
- linux-x64, linux-arm64
|
||||
- osx-x64, osx-arm64
|
||||
- win-x64
|
||||
|
||||
Auto-update features:
|
||||
- Checks for updates every 24 hours via GitHub API
|
||||
- SHA256 checksum verification before installation
|
||||
- Atomic update with rollback on failure
|
||||
- Windows file locking detection
|
||||
|
||||
You can pass the following entries in ls_specific_settings["pascal"]:
|
||||
- (reserved for future use)
|
||||
|
||||
Environment variables (recommended for CodeTools configuration):
|
||||
- pp: Path to FPC compiler driver, must be "fpc.exe" (e.g., "D:/laz32/fpc/bin/i386-win32/fpc.exe").
|
||||
Do NOT use backend compilers like ppc386.exe or ppcx64.exe - CodeTools queries fpc.exe for
|
||||
configuration (fpc -iV, fpc -iTO, etc.). This is the most important setting for hover/navigation.
|
||||
- fpcdir: Path to FPC source directory (e.g., "D:/laz32/fpcsrc"). Helps CodeTools locate
|
||||
standard library sources for better navigation.
|
||||
- lazarusdir: Path to Lazarus directory (e.g., "D:/laz32/lazarus"). Required for Lazarus
|
||||
projects using LCL and other Lazarus components.
|
||||
|
||||
Target platform overrides (use only if pp setting is not sufficient):
|
||||
- fpc_target: Override target OS (e.g., "Win32", "Win64", "Linux"). Sets FPCTARGET env var.
|
||||
- fpc_target_cpu: Override target CPU (e.g., "i386", "x86_64", "aarch64"). Sets FPCTARGETCPU.
|
||||
|
||||
Example configuration in ~/.serena/serena_config.yml:
|
||||
ls_specific_settings:
|
||||
pascal:
|
||||
pp: "D:/laz32/fpc/bin/i386-win32/fpc.exe"
|
||||
fpcdir: "D:/laz32/fpcsrc"
|
||||
lazarusdir: "D:/laz32/lazarus"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import shutil
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, quote_windows_path
|
||||
from solidlsp.ls import SolidLanguageServer
|
||||
@@ -37,8 +73,18 @@ class PascalLanguageServer(SolidLanguageServer):
|
||||
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"
|
||||
# URL configuration
|
||||
PASLS_RELEASES_URL = "https://github.com/zen010101/pascal-language-server/releases/latest/download"
|
||||
PASLS_API_URL = "https://api.github.com/repos/zen010101/pascal-language-server/releases/latest"
|
||||
|
||||
# Update check interval (seconds)
|
||||
UPDATE_CHECK_INTERVAL = 86400 # 24 hours
|
||||
|
||||
# Metadata directory name
|
||||
META_DIR = ".meta"
|
||||
|
||||
# Network timeout (seconds)
|
||||
NETWORK_TIMEOUT = 10
|
||||
|
||||
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
|
||||
"""
|
||||
@@ -46,20 +92,523 @@ class PascalLanguageServer(SolidLanguageServer):
|
||||
Use LanguageServer.create() instead.
|
||||
"""
|
||||
pasls_executable_path = self._setup_runtime_dependencies(solidlsp_settings)
|
||||
|
||||
# Build environment variables for pasls
|
||||
# These control CodeTools' configuration and target platform settings
|
||||
proc_env: dict[str, str] = {}
|
||||
|
||||
# Read from ls_specific_settings["pascal"]
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
pascal_settings = solidlsp_settings.get_ls_specific_settings(Language.PASCAL)
|
||||
|
||||
# pp: Path to FPC compiler driver (must be fpc.exe, NOT ppc386.exe/ppcx64.exe)
|
||||
# CodeTools queries fpc.exe for configuration via "fpc -iV", "fpc -iTO", etc.
|
||||
pp = pascal_settings.get("pp", "")
|
||||
if pp:
|
||||
proc_env["PP"] = pp
|
||||
log.info(f"Setting PP={pp} from ls_specific_settings")
|
||||
|
||||
# fpcdir: Path to FPC source directory (e.g., "D:/laz32/fpcsrc")
|
||||
fpcdir = pascal_settings.get("fpcdir", "")
|
||||
if fpcdir:
|
||||
proc_env["FPCDIR"] = fpcdir
|
||||
log.info(f"Setting FPCDIR={fpcdir} from ls_specific_settings")
|
||||
|
||||
# lazarusdir: Path to Lazarus directory (e.g., "D:/laz32/lazarus")
|
||||
lazarusdir = pascal_settings.get("lazarusdir", "")
|
||||
if lazarusdir:
|
||||
proc_env["LAZARUSDIR"] = lazarusdir
|
||||
log.info(f"Setting LAZARUSDIR={lazarusdir} from ls_specific_settings")
|
||||
|
||||
# fpc_target: Override target OS (e.g., "Win32", "Win64", "Linux")
|
||||
fpc_target = pascal_settings.get("fpc_target", "")
|
||||
if fpc_target:
|
||||
proc_env["FPCTARGET"] = fpc_target
|
||||
log.info(f"Setting FPCTARGET={fpc_target} from ls_specific_settings")
|
||||
|
||||
# fpc_target_cpu: Override target CPU (e.g., "i386", "x86_64", "aarch64")
|
||||
fpc_target_cpu = pascal_settings.get("fpc_target_cpu", "")
|
||||
if fpc_target_cpu:
|
||||
proc_env["FPCTARGETCPU"] = fpc_target_cpu
|
||||
log.info(f"Setting FPCTARGETCPU={fpc_target_cpu} from ls_specific_settings")
|
||||
|
||||
super().__init__(
|
||||
config,
|
||||
repository_root_path,
|
||||
ProcessLaunchInfo(cmd=pasls_executable_path, cwd=repository_root_path),
|
||||
ProcessLaunchInfo(cmd=pasls_executable_path, cwd=repository_root_path, env=proc_env),
|
||||
"pascal",
|
||||
solidlsp_settings,
|
||||
)
|
||||
self.server_ready = threading.Event()
|
||||
self.completions_available_event = threading.Event()
|
||||
|
||||
# ============== Metadata Directory Management ==============
|
||||
|
||||
@classmethod
|
||||
def _meta_dir(cls, pasls_dir: str) -> str:
|
||||
"""Get metadata directory path, create if not exists."""
|
||||
meta_path = os.path.join(pasls_dir, cls.META_DIR)
|
||||
os.makedirs(meta_path, exist_ok=True)
|
||||
return meta_path
|
||||
|
||||
@classmethod
|
||||
def _meta_file(cls, pasls_dir: str, filename: str) -> str:
|
||||
"""Get metadata file path."""
|
||||
return os.path.join(cls._meta_dir(pasls_dir), filename)
|
||||
|
||||
# ============== Version Management ==============
|
||||
|
||||
@staticmethod
|
||||
def _normalize_version(version: str | None) -> str:
|
||||
"""Normalize version string by removing 'v' prefix and whitespace."""
|
||||
if not version:
|
||||
return ""
|
||||
return version.strip().lstrip("vV")
|
||||
|
||||
@classmethod
|
||||
def _is_newer_version(cls, latest: str | None, local: str | None) -> bool:
|
||||
"""Compare versions, return True if latest is newer than local."""
|
||||
if not latest:
|
||||
return False
|
||||
if not local:
|
||||
return True
|
||||
|
||||
latest_norm = cls._normalize_version(latest)
|
||||
local_norm = cls._normalize_version(local)
|
||||
|
||||
if not latest_norm:
|
||||
return False
|
||||
if not local_norm:
|
||||
return True
|
||||
|
||||
try:
|
||||
|
||||
def parse_version(v: str) -> list[int]:
|
||||
parts = []
|
||||
for part in v.split("."):
|
||||
num = ""
|
||||
for c in part:
|
||||
if c.isdigit():
|
||||
num += c
|
||||
else:
|
||||
break
|
||||
parts.append(int(num) if num else 0)
|
||||
return parts
|
||||
|
||||
latest_parts = parse_version(latest_norm)
|
||||
local_parts = parse_version(local_norm)
|
||||
|
||||
# Pad to same length
|
||||
max_len = max(len(latest_parts), len(local_parts))
|
||||
latest_parts.extend([0] * (max_len - len(latest_parts)))
|
||||
local_parts.extend([0] * (max_len - len(local_parts)))
|
||||
|
||||
return latest_parts > local_parts
|
||||
except Exception:
|
||||
log.warning(f"Failed to parse versions for comparison: {latest_norm} vs {local_norm}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _get_latest_version(cls) -> str | None:
|
||||
"""Get latest version from GitHub API, return None on failure."""
|
||||
try:
|
||||
headers = {"Accept": "application/vnd.github.v3+json", "User-Agent": "Serena-LSP"}
|
||||
# Support GITHUB_TOKEN for CI environments with rate limits
|
||||
github_token = os.environ.get("GITHUB_TOKEN")
|
||||
if github_token:
|
||||
headers["Authorization"] = f"token {github_token}"
|
||||
|
||||
req = urllib.request.Request(cls.PASLS_API_URL, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=cls.NETWORK_TIMEOUT) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
return data.get("tag_name")
|
||||
except Exception as e:
|
||||
log.debug(f"Failed to get latest pasls version: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _get_local_version(cls, pasls_dir: str) -> str | None:
|
||||
"""Read local version file."""
|
||||
version_file = cls._meta_file(pasls_dir, "version")
|
||||
if os.path.exists(version_file):
|
||||
try:
|
||||
with open(version_file, encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _save_local_version(cls, pasls_dir: str, version: str) -> None:
|
||||
"""Save version to local file."""
|
||||
version_file = cls._meta_file(pasls_dir, "version")
|
||||
try:
|
||||
with open(version_file, "w", encoding="utf-8") as f:
|
||||
f.write(version)
|
||||
except OSError as e:
|
||||
log.warning(f"Failed to save version file: {e}")
|
||||
|
||||
# ============== Update Check Timing ==============
|
||||
|
||||
@classmethod
|
||||
def _should_check_update(cls, pasls_dir: str) -> bool:
|
||||
"""Check if we should query for updates (more than 24 hours since last check)."""
|
||||
last_check_file = cls._meta_file(pasls_dir, "last_check")
|
||||
if not os.path.exists(last_check_file):
|
||||
return True
|
||||
try:
|
||||
with open(last_check_file, encoding="utf-8") as f:
|
||||
last_check = float(f.read().strip())
|
||||
return (time.time() - last_check) > cls.UPDATE_CHECK_INTERVAL
|
||||
except (OSError, ValueError):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _update_last_check(cls, pasls_dir: str) -> None:
|
||||
"""Update last check timestamp."""
|
||||
last_check_file = cls._meta_file(pasls_dir, "last_check")
|
||||
try:
|
||||
with open(last_check_file, "w", encoding="utf-8") as f:
|
||||
f.write(str(time.time()))
|
||||
except OSError as e:
|
||||
log.warning(f"Failed to update last check time: {e}")
|
||||
|
||||
# ============== SHA256 Checksum ==============
|
||||
|
||||
@classmethod
|
||||
def _get_checksums(cls) -> dict[str, str] | None:
|
||||
"""Download checksums file from GitHub, return {filename: sha256} dict."""
|
||||
checksums_url = f"{cls.PASLS_RELEASES_URL}/checksums.sha256"
|
||||
try:
|
||||
req = urllib.request.Request(checksums_url, headers={"User-Agent": "Serena-LSP"})
|
||||
with urllib.request.urlopen(req, timeout=cls.NETWORK_TIMEOUT) as response:
|
||||
content = response.read().decode("utf-8")
|
||||
checksums = {}
|
||||
for line in content.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
sha256 = parts[0]
|
||||
filename = parts[1].lstrip("*") # Remove possible * prefix
|
||||
checksums[filename] = sha256
|
||||
return checksums
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to get checksums: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _calculate_sha256(file_path: str) -> str:
|
||||
"""Calculate SHA256 checksum of a file."""
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha256_hash.update(chunk)
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
@classmethod
|
||||
def _verify_checksum(cls, file_path: str, expected_sha256: str) -> bool:
|
||||
"""Verify file checksum."""
|
||||
try:
|
||||
actual_sha256 = cls._calculate_sha256(file_path)
|
||||
if actual_sha256.lower() == expected_sha256.lower():
|
||||
log.debug(f"Checksum verified: {file_path}")
|
||||
return True
|
||||
else:
|
||||
log.error(f"Checksum mismatch for {file_path}: expected {expected_sha256}, got {actual_sha256}")
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(f"Failed to verify checksum: {e}")
|
||||
return False
|
||||
|
||||
# ============== Windows File Locking ==============
|
||||
|
||||
@staticmethod
|
||||
def _is_file_locked(file_path: str) -> bool:
|
||||
"""Check if file is locked (Windows)."""
|
||||
if platform.system() != "Windows":
|
||||
return False
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(file_path, "a"):
|
||||
pass
|
||||
return False
|
||||
except (OSError, PermissionError):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _safe_remove(cls, file_path: str) -> bool:
|
||||
"""Safely remove file, handle Windows file locking."""
|
||||
if not os.path.exists(file_path):
|
||||
return True
|
||||
|
||||
if platform.system() == "Windows" and cls._is_file_locked(file_path):
|
||||
temp_name = f"{file_path}.old.{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
os.rename(file_path, temp_name)
|
||||
log.info(f"File locked, renamed to: {temp_name}")
|
||||
cls._mark_for_cleanup(os.path.dirname(file_path), temp_name)
|
||||
return True
|
||||
except PermissionError:
|
||||
log.warning(f"Cannot remove/rename locked file: {file_path}")
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
return True
|
||||
except OSError as e:
|
||||
log.warning(f"Failed to remove file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _mark_for_cleanup(cls, pasls_dir: str, file_path: str) -> None:
|
||||
"""Mark file for later cleanup."""
|
||||
cleanup_file = cls._meta_file(pasls_dir, "cleanup_list")
|
||||
try:
|
||||
with open(cleanup_file, "a", encoding="utf-8") as f:
|
||||
f.write(file_path + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _cleanup_old_files(cls, pasls_dir: str) -> None:
|
||||
"""Clean up old files marked for deletion."""
|
||||
cleanup_file = cls._meta_file(pasls_dir, "cleanup_list")
|
||||
if not os.path.exists(cleanup_file):
|
||||
return
|
||||
|
||||
try:
|
||||
with open(cleanup_file, encoding="utf-8") as f:
|
||||
files = [line.strip() for line in f if line.strip()]
|
||||
|
||||
remaining = []
|
||||
for file_path in files:
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
log.debug(f"Cleaned up old file: {file_path}")
|
||||
except OSError:
|
||||
remaining.append(file_path)
|
||||
|
||||
if remaining:
|
||||
with open(cleanup_file, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(remaining) + "\n")
|
||||
else:
|
||||
os.remove(cleanup_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ============== Download and Atomic Update ==============
|
||||
|
||||
@classmethod
|
||||
def _download_archive(cls, url: str, target_path: str) -> bool:
|
||||
"""Download archive to specified path."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Serena-LSP"})
|
||||
with urllib.request.urlopen(req, timeout=60) as response:
|
||||
with open(target_path, "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Failed to download {url}: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _is_safe_tar_member(cls, member: tarfile.TarInfo, target_dir: str) -> bool:
|
||||
"""Check if tar member is safe (prevent path traversal attack)."""
|
||||
# Check for .. in path components
|
||||
if ".." in member.name.split("/") or ".." in member.name.split("\\"):
|
||||
return False
|
||||
|
||||
# Check extracted path is within target directory
|
||||
abs_target = os.path.abspath(target_dir)
|
||||
abs_member = os.path.abspath(os.path.join(target_dir, member.name))
|
||||
|
||||
return abs_member.startswith(abs_target + os.sep) or abs_member == abs_target
|
||||
|
||||
@classmethod
|
||||
def _extract_archive(cls, archive_path: str, target_dir: str, archive_type: str) -> bool:
|
||||
"""Safely extract archive to specified directory."""
|
||||
try:
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
if archive_type == "gztar":
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
for member in tar.getmembers():
|
||||
if not cls._is_safe_tar_member(member, target_dir):
|
||||
log.error(f"Unsafe tar member detected (path traversal): {member.name}")
|
||||
return False
|
||||
tar.extractall(target_dir)
|
||||
|
||||
elif archive_type == "zip":
|
||||
with zipfile.ZipFile(archive_path, "r") as zip_ref:
|
||||
for name in zip_ref.namelist():
|
||||
if ".." in name.split("/") or ".." in name.split("\\"):
|
||||
log.error(f"Unsafe zip member detected (path traversal): {name}")
|
||||
return False
|
||||
abs_target = os.path.abspath(target_dir)
|
||||
abs_member = os.path.abspath(os.path.join(target_dir, name))
|
||||
if not (abs_member.startswith(abs_target + os.sep) or abs_member == abs_target):
|
||||
log.error(f"Unsafe zip member detected (path traversal): {name}")
|
||||
return False
|
||||
zip_ref.extractall(target_dir)
|
||||
|
||||
else:
|
||||
log.error(f"Unsupported archive type: {archive_type}")
|
||||
return False
|
||||
|
||||
# Handle nested directory: if extraction created a single subdirectory,
|
||||
# move its contents up to target_dir (common with GitHub release archives)
|
||||
cls._flatten_single_subdir(target_dir)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Failed to extract archive: {type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _flatten_single_subdir(cls, target_dir: str) -> None:
|
||||
"""If target_dir contains only a single subdirectory, move its contents up."""
|
||||
entries = os.listdir(target_dir)
|
||||
if len(entries) == 1:
|
||||
subdir = os.path.join(target_dir, entries[0])
|
||||
if os.path.isdir(subdir):
|
||||
# Move all contents from subdir to target_dir
|
||||
for item in os.listdir(subdir):
|
||||
src = os.path.join(subdir, item)
|
||||
dst = os.path.join(target_dir, item)
|
||||
shutil.move(src, dst)
|
||||
# Remove the now-empty subdirectory
|
||||
os.rmdir(subdir)
|
||||
|
||||
@classmethod
|
||||
def _get_archive_filename(cls, dep: RuntimeDependency) -> str:
|
||||
"""Get archive filename from URL."""
|
||||
assert dep.url is not None, "RuntimeDependency.url must be set"
|
||||
return dep.url.split("/")[-1]
|
||||
|
||||
@classmethod
|
||||
def _atomic_install(cls, pasls_dir: str, deps: RuntimeDependencyCollection, checksums: dict[str, str] | None) -> bool:
|
||||
"""Atomic update: download -> verify checksum -> extract -> replace."""
|
||||
temp_dir = pasls_dir + ".tmp"
|
||||
backup_dir = pasls_dir + ".backup"
|
||||
temp_archive_dir = os.path.join(os.path.expanduser("~"), "solidlsp_tmp")
|
||||
|
||||
try:
|
||||
dep = deps.get_single_dep_for_current_platform()
|
||||
assert dep.url is not None, "RuntimeDependency.url must be set"
|
||||
assert dep.archive_type is not None, "RuntimeDependency.archive_type must be set"
|
||||
|
||||
archive_filename = cls._get_archive_filename(dep)
|
||||
archive_path = os.path.join(temp_archive_dir, archive_filename)
|
||||
|
||||
# 1. Clean up any existing temp directory
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir)
|
||||
os.makedirs(temp_archive_dir, exist_ok=True)
|
||||
|
||||
# 2. Download archive
|
||||
log.info(f"Downloading pasls archive: {archive_filename}")
|
||||
if not cls._download_archive(dep.url, archive_path):
|
||||
log.error("Failed to download pasls archive")
|
||||
return False
|
||||
|
||||
# 3. Verify SHA256 checksum (critical security step, before extraction)
|
||||
if checksums:
|
||||
expected_sha256 = checksums.get(archive_filename)
|
||||
if expected_sha256:
|
||||
log.info(f"Verifying SHA256 checksum for {archive_filename}...")
|
||||
if not cls._verify_checksum(archive_path, expected_sha256):
|
||||
log.error(f"SHA256 checksum verification FAILED for {archive_filename}")
|
||||
log.error("Aborting installation due to checksum mismatch - possible security issue!")
|
||||
try:
|
||||
os.remove(archive_path)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
log.info("SHA256 checksum verified successfully")
|
||||
else:
|
||||
log.warning(f"No checksum found for {archive_filename} in checksums file")
|
||||
else:
|
||||
log.warning("No checksums available - skipping verification (not recommended for production)")
|
||||
|
||||
# 4. Extract to temp directory
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
log.info("Extracting archive to temporary directory...")
|
||||
if not cls._extract_archive(archive_path, temp_dir, dep.archive_type):
|
||||
log.error("Failed to extract archive")
|
||||
return False
|
||||
|
||||
# 5. Set execute permission
|
||||
binary_path = deps.binary_path(temp_dir)
|
||||
if os.path.exists(binary_path):
|
||||
try:
|
||||
os.chmod(binary_path, 0o755)
|
||||
except OSError:
|
||||
pass # May fail on Windows
|
||||
|
||||
# 6. Backup old version
|
||||
if os.path.exists(pasls_dir):
|
||||
if os.path.exists(backup_dir):
|
||||
shutil.rmtree(backup_dir)
|
||||
shutil.move(pasls_dir, backup_dir)
|
||||
|
||||
# 7. Replace with new version
|
||||
shutil.move(temp_dir, pasls_dir)
|
||||
|
||||
# 8. Restore meta directory from backup (preserves version info, last_check, etc.)
|
||||
if os.path.exists(backup_dir):
|
||||
backup_meta = os.path.join(backup_dir, cls.META_DIR)
|
||||
if os.path.exists(backup_meta):
|
||||
target_meta = os.path.join(pasls_dir, cls.META_DIR)
|
||||
if not os.path.exists(target_meta):
|
||||
shutil.copytree(backup_meta, target_meta)
|
||||
|
||||
# 9. Clean up downloaded archive and temp directory
|
||||
try:
|
||||
os.remove(archive_path)
|
||||
os.rmdir(temp_archive_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
log.info("pasls installation completed successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Installation failed: {e}")
|
||||
|
||||
# Rollback
|
||||
if os.path.exists(backup_dir) and not os.path.exists(pasls_dir):
|
||||
try:
|
||||
shutil.move(backup_dir, pasls_dir)
|
||||
log.info("Rolled back to previous version")
|
||||
except Exception as rollback_error:
|
||||
log.error(f"Rollback failed: {rollback_error}")
|
||||
|
||||
# Clean up temp directory
|
||||
if os.path.exists(temp_dir):
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str:
|
||||
"""
|
||||
Setup runtime dependencies for Pascal Language Server (pasls).
|
||||
Automatically checks for updates every 24 hours with security verification.
|
||||
|
||||
Returns:
|
||||
str: The command to start the pasls server
|
||||
@@ -71,13 +620,21 @@ class PascalLanguageServer(SolidLanguageServer):
|
||||
log.info(f"Found pasls in PATH: {pasls_in_path}")
|
||||
return quote_windows_path(pasls_in_path)
|
||||
|
||||
# Use RuntimeDependencyCollection for download
|
||||
pasls_dir = cls.ls_resources_dir(solidlsp_settings)
|
||||
os.makedirs(pasls_dir, exist_ok=True)
|
||||
|
||||
# Clean up old files from previous sessions
|
||||
cls._cleanup_old_files(pasls_dir)
|
||||
|
||||
# Use RuntimeDependencyCollection for platform detection
|
||||
# Asset names follow zen010101/pascal-language-server release convention:
|
||||
# pasls-{cpu_arch}-{os}.{ext} where cpu_arch is x86_64/aarch64/i386
|
||||
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",
|
||||
url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-linux.tar.gz",
|
||||
platform_id="linux-x64",
|
||||
archive_type="gztar",
|
||||
binary_name="pasls",
|
||||
@@ -85,7 +642,7 @@ class PascalLanguageServer(SolidLanguageServer):
|
||||
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",
|
||||
url=f"{cls.PASLS_RELEASES_URL}/pasls-aarch64-linux.tar.gz",
|
||||
platform_id="linux-arm64",
|
||||
archive_type="gztar",
|
||||
binary_name="pasls",
|
||||
@@ -93,23 +650,23 @@ class PascalLanguageServer(SolidLanguageServer):
|
||||
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",
|
||||
url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-darwin.zip",
|
||||
platform_id="osx-x64",
|
||||
archive_type="gztar",
|
||||
archive_type="zip",
|
||||
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",
|
||||
url=f"{cls.PASLS_RELEASES_URL}/pasls-aarch64-darwin.zip",
|
||||
platform_id="osx-arm64",
|
||||
archive_type="gztar",
|
||||
archive_type="zip",
|
||||
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",
|
||||
url=f"{cls.PASLS_RELEASES_URL}/pasls-x86_64-win64.zip",
|
||||
platform_id="win-x64",
|
||||
archive_type="zip",
|
||||
binary_name="pasls.exe",
|
||||
@@ -117,17 +674,65 @@ class PascalLanguageServer(SolidLanguageServer):
|
||||
]
|
||||
)
|
||||
|
||||
pasls_dir = cls.ls_resources_dir(solidlsp_settings)
|
||||
pasls_executable_path = deps.binary_path(pasls_dir)
|
||||
|
||||
# Determine if download is needed
|
||||
need_download = False
|
||||
latest_version = None
|
||||
checksums = None
|
||||
|
||||
if not os.path.exists(pasls_executable_path):
|
||||
log.info(f"Downloading pasls to {pasls_dir}...")
|
||||
deps.install(pasls_dir)
|
||||
# First install
|
||||
log.info("pasls not found, will download...")
|
||||
need_download = True
|
||||
latest_version = cls._get_latest_version()
|
||||
checksums = cls._get_checksums()
|
||||
elif cls._should_check_update(pasls_dir):
|
||||
# Check for updates
|
||||
log.debug("Checking for pasls updates...")
|
||||
latest_version = cls._get_latest_version()
|
||||
local_version = cls._get_local_version(pasls_dir)
|
||||
|
||||
if cls._is_newer_version(latest_version, local_version):
|
||||
log.info(f"New pasls version available: {latest_version} (current: {local_version})")
|
||||
|
||||
# Check Windows file locking
|
||||
if cls._is_file_locked(pasls_executable_path):
|
||||
log.warning("Cannot update pasls: file is in use. Will retry next time.")
|
||||
else:
|
||||
need_download = True
|
||||
checksums = cls._get_checksums()
|
||||
else:
|
||||
log.debug(f"pasls is up to date: {local_version}")
|
||||
|
||||
if need_download:
|
||||
if cls._atomic_install(pasls_dir, deps, checksums):
|
||||
# Update metadata after successful installation
|
||||
if latest_version:
|
||||
cls._save_local_version(pasls_dir, latest_version)
|
||||
else:
|
||||
# API failed but download succeeded, record placeholder version
|
||||
cls._save_local_version(pasls_dir, "unknown")
|
||||
cls._update_last_check(pasls_dir)
|
||||
else:
|
||||
# Installation failed, use existing version if available
|
||||
if not os.path.exists(pasls_executable_path):
|
||||
raise RuntimeError("Failed to install pasls and no local version available")
|
||||
log.warning("Update failed, using existing version")
|
||||
|
||||
# Update check time even if no update (avoid frequent checks)
|
||||
if not need_download and cls._should_check_update(pasls_dir):
|
||||
cls._update_last_check(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}")
|
||||
|
||||
# Ensure execute permission
|
||||
try:
|
||||
os.chmod(pasls_executable_path, 0o755)
|
||||
except OSError:
|
||||
pass # May fail on Windows, ignore
|
||||
|
||||
log.info(f"Using pasls at: {pasls_executable_path}")
|
||||
return quote_windows_path(pasls_executable_path)
|
||||
|
||||
@staticmethod
|
||||
|
||||
+92
-56
@@ -59,37 +59,83 @@ class ReferenceInSymbol:
|
||||
character: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class LSPFileBuffer:
|
||||
"""
|
||||
This class is used to store the contents of an open LSP file in memory.
|
||||
"""
|
||||
|
||||
# uri of the file
|
||||
uri: str
|
||||
def __init__(self, uri: str, contents: str, encoding: str, version: int, language_id: str, ref_count: int) -> None:
|
||||
self.uri = uri
|
||||
self.contents = contents
|
||||
self.version = version
|
||||
self.language_id = language_id
|
||||
self.ref_count = ref_count
|
||||
self.encoding = encoding
|
||||
self._content_hash: str | None = None
|
||||
|
||||
# The contents of the file
|
||||
contents: str
|
||||
|
||||
# The version of the file
|
||||
version: int
|
||||
|
||||
# The language id of the file
|
||||
language_id: str
|
||||
|
||||
# reference count of the file
|
||||
ref_count: int
|
||||
|
||||
content_hash: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.content_hash = hashlib.md5(self.contents.encode("utf-8")).hexdigest()
|
||||
@property
|
||||
def content_hash(self) -> str:
|
||||
if self._content_hash is None:
|
||||
self._content_hash = hashlib.md5(self.contents.encode(self.encoding)).hexdigest()
|
||||
return self._content_hash
|
||||
|
||||
def split_lines(self) -> list[str]:
|
||||
"""Splits the contents of the file into lines."""
|
||||
return self.contents.split("\n")
|
||||
|
||||
|
||||
class SymbolBody:
|
||||
"""
|
||||
Representation of the body of a symbol, which allows the extraction of the symbol's text
|
||||
from the lines of the file it is defined in.
|
||||
|
||||
Instances that share the same lines buffer are memory-efficient,
|
||||
using only 4 integers and a reference to the lines buffer from which the text can be extracted,
|
||||
i.e. a core representation of only about 40 bytes per body.
|
||||
"""
|
||||
|
||||
def __init__(self, lines: list[str], start_line: int, start_col: int, end_line: int, end_col: int) -> None:
|
||||
self._lines = lines
|
||||
self._start_line = start_line
|
||||
self._start_col = start_col
|
||||
self._end_line = end_line
|
||||
self._end_col = end_col
|
||||
|
||||
def get_text(self) -> str:
|
||||
# extract relevant lines
|
||||
symbol_body = "\n".join(self._lines[self._start_line : self._end_line + 1])
|
||||
|
||||
# remove leading indentation
|
||||
symbol_body = symbol_body[self._start_col :]
|
||||
|
||||
# TODO: handle end_col properly (this was never implemented)
|
||||
|
||||
return symbol_body
|
||||
|
||||
|
||||
class SymbolBodyFactory:
|
||||
"""
|
||||
A factory for the creation of SymbolBody instances from symbols dictionaries.
|
||||
Instances created from the same factory instance are memory-efficient, as they share
|
||||
the same lines buffer.
|
||||
"""
|
||||
|
||||
def __init__(self, file_buffer: LSPFileBuffer):
|
||||
self._lines = file_buffer.split_lines()
|
||||
|
||||
def create_symbol_body(self, symbol: GenericDocumentSymbol) -> SymbolBody:
|
||||
existing_body = symbol.get("body", None)
|
||||
if existing_body and isinstance(existing_body, SymbolBody):
|
||||
return existing_body
|
||||
|
||||
assert "location" in symbol
|
||||
start_line = symbol["location"]["range"]["start"]["line"] # type: ignore
|
||||
end_line = symbol["location"]["range"]["end"]["line"] # type: ignore
|
||||
start_col = symbol["location"]["range"]["start"]["character"] # type: ignore
|
||||
end_col = symbol["location"]["range"]["end"]["character"] # type: ignore
|
||||
return SymbolBody(self._lines, start_line, start_col, end_line, end_col)
|
||||
|
||||
|
||||
class DocumentSymbols:
|
||||
# IMPORTANT: Instances of this class are persisted in the high-level document symbol cache
|
||||
|
||||
@@ -212,7 +258,7 @@ class SolidLanguageServer(ABC):
|
||||
"""
|
||||
RAW_DOCUMENT_SYMBOL_CACHE_FILENAME = "raw_document_symbols.pkl"
|
||||
RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK = "document_symbols_cache_v23-06-25.pkl"
|
||||
DOCUMENT_SYMBOL_CACHE_VERSION = 3
|
||||
DOCUMENT_SYMBOL_CACHE_VERSION = 4
|
||||
DOCUMENT_SYMBOL_CACHE_FILENAME = "document_symbols.pkl"
|
||||
|
||||
# To be overridden and extended by subclasses
|
||||
@@ -590,7 +636,9 @@ class SolidLanguageServer(ABC):
|
||||
|
||||
version = 0
|
||||
language_id = self._get_language_id_for_file(relative_file_path)
|
||||
self.open_file_buffers[uri] = LSPFileBuffer(uri, contents, version, language_id, 1)
|
||||
self.open_file_buffers[uri] = LSPFileBuffer(
|
||||
uri=uri, contents=contents, encoding=self._encoding, version=version, language_id=language_id, ref_count=1
|
||||
)
|
||||
|
||||
self.server.notify.did_open_text_document(
|
||||
{
|
||||
@@ -1133,7 +1181,7 @@ class SolidLanguageServer(ABC):
|
||||
assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}"
|
||||
log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path)
|
||||
|
||||
file_lines = file_data.split_lines()
|
||||
body_factory = SymbolBodyFactory(file_data)
|
||||
|
||||
def convert_to_unified_symbol(original_symbol_dict: GenericDocumentSymbol) -> ls_types.UnifiedSymbolInformation:
|
||||
"""
|
||||
@@ -1164,8 +1212,7 @@ class SolidLanguageServer(ABC):
|
||||
if "relativePath" not in location:
|
||||
location["relativePath"] = relative_file_path # type: ignore
|
||||
|
||||
if "body" not in item:
|
||||
item["body"] = self.retrieve_symbol_body(item, file_lines=file_lines)
|
||||
item["body"] = self.create_symbol_body(item, factory=body_factory)
|
||||
|
||||
# handle missing selectionRange
|
||||
if "selectionRange" not in item:
|
||||
@@ -1502,32 +1549,17 @@ class SolidLanguageServer(ABC):
|
||||
|
||||
return ls_types.SignatureHelp(**response) # type: ignore
|
||||
|
||||
def retrieve_symbol_body(
|
||||
def create_symbol_body(
|
||||
self,
|
||||
symbol: ls_types.UnifiedSymbolInformation | LSPTypes.SymbolInformation,
|
||||
file_lines: list[str] | None = None,
|
||||
file_buffer: LSPFileBuffer | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Load the body of the given symbol. If the body is already contained in the symbol, just return it.
|
||||
"""
|
||||
existing_body = symbol.get("body", None)
|
||||
if existing_body:
|
||||
return str(existing_body)
|
||||
factory: SymbolBodyFactory | None = None,
|
||||
) -> SymbolBody:
|
||||
if factory is None:
|
||||
assert "relativePath" in symbol["location"]
|
||||
with self._open_file_context(symbol["location"]["relativePath"]) as f: # type: ignore
|
||||
factory = SymbolBodyFactory(f)
|
||||
|
||||
assert "location" in symbol
|
||||
symbol_start_line = symbol["location"]["range"]["start"]["line"]
|
||||
symbol_end_line = symbol["location"]["range"]["end"]["line"]
|
||||
assert "relativePath" in symbol["location"]
|
||||
if file_lines is None:
|
||||
with self._open_file_context(symbol["location"]["relativePath"], file_buffer) as f: # type: ignore
|
||||
file_lines = f.split_lines()
|
||||
symbol_body = "\n".join(file_lines[symbol_start_line : symbol_end_line + 1])
|
||||
|
||||
# remove leading indentation
|
||||
symbol_start_column = symbol["location"]["range"]["start"]["character"] # type: ignore
|
||||
symbol_body = symbol_body[symbol_start_column:]
|
||||
return symbol_body
|
||||
return factory.create_symbol_body(symbol)
|
||||
|
||||
def request_referencing_symbols(
|
||||
self,
|
||||
@@ -1578,8 +1610,12 @@ class SolidLanguageServer(ABC):
|
||||
ref_col = ref["range"]["start"]["character"]
|
||||
|
||||
with self.open_file(ref_path) as file_data:
|
||||
body_factory = SymbolBodyFactory(file_data)
|
||||
|
||||
# Get the containing symbol for this reference
|
||||
containing_symbol = self.request_containing_symbol(ref_path, ref_line, ref_col, include_body=include_body)
|
||||
containing_symbol = self.request_containing_symbol(
|
||||
ref_path, ref_line, ref_col, include_body=include_body, body_factory=body_factory
|
||||
)
|
||||
if containing_symbol is None:
|
||||
# TODO: HORRIBLE HACK! I don't know how to do it better for now...
|
||||
# THIS IS BOUND TO BREAK IN MANY CASES! IT IS ALSO SPECIFIC TO PYTHON!
|
||||
@@ -1617,11 +1653,6 @@ class SolidLanguageServer(ABC):
|
||||
)
|
||||
name = os.path.splitext(os.path.basename(ref_path))[0]
|
||||
|
||||
if include_body:
|
||||
body = self.retrieve_full_file_content(ref_path)
|
||||
else:
|
||||
body = ""
|
||||
|
||||
containing_symbol = ls_types.UnifiedSymbolInformation(
|
||||
kind=ls_types.SymbolKind.File,
|
||||
range=fileRange,
|
||||
@@ -1629,8 +1660,11 @@ class SolidLanguageServer(ABC):
|
||||
location=location,
|
||||
name=name,
|
||||
children=[],
|
||||
body=body,
|
||||
)
|
||||
|
||||
if include_body:
|
||||
containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory)
|
||||
|
||||
if containing_symbol is None or (not include_file_symbols and containing_symbol["kind"] == ls_types.SymbolKind.File):
|
||||
continue
|
||||
|
||||
@@ -1689,6 +1723,7 @@ class SolidLanguageServer(ABC):
|
||||
column: int | None = None,
|
||||
strict: bool = False,
|
||||
include_body: bool = False,
|
||||
body_factory: SymbolBodyFactory | None = None,
|
||||
) -> ls_types.UnifiedSymbolInformation | None:
|
||||
"""
|
||||
Finds the first symbol containing the position for the given file.
|
||||
@@ -1788,7 +1823,7 @@ class SolidLanguageServer(ABC):
|
||||
# Return the one with the greatest starting position (i.e. the innermost container).
|
||||
containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"])
|
||||
if include_body:
|
||||
containing_symbol["body"] = self.retrieve_symbol_body(containing_symbol)
|
||||
containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory)
|
||||
return containing_symbol
|
||||
else:
|
||||
return None
|
||||
@@ -2056,7 +2091,8 @@ class SolidLanguageServer(ABC):
|
||||
newName=new_name,
|
||||
)
|
||||
|
||||
return self.server.send.rename(params)
|
||||
with self.open_file(relative_file_path):
|
||||
return self.server.send.rename(params)
|
||||
|
||||
def apply_text_edits_to_file(self, relative_path: str, edits: list[ls_types.TextEdit]) -> None:
|
||||
"""
|
||||
|
||||
@@ -5,12 +5,16 @@ Defines wrapper objects around the types returned by LSP to ensure decoupling be
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum, IntEnum
|
||||
from typing import NotRequired, Union
|
||||
from typing import TYPE_CHECKING, NotRequired, Union
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .ls import SymbolBody
|
||||
|
||||
|
||||
URI = str
|
||||
DocumentUri = str
|
||||
Uint = int
|
||||
@@ -187,8 +191,12 @@ class SymbolTag(IntEnum):
|
||||
|
||||
|
||||
class UnifiedSymbolInformation(TypedDict):
|
||||
"""Represents information about programming constructs like variables, classes,
|
||||
"""
|
||||
Represents information about programming constructs like variables, classes,
|
||||
interfaces etc.
|
||||
|
||||
This is a unifying extension of `lsp_types.SymbolInformation` and `lsp_types.DocumentSymbol`,
|
||||
with added fields for SolidLSP/Serena use.
|
||||
"""
|
||||
|
||||
deprecated: NotRequired[bool]
|
||||
@@ -234,7 +242,7 @@ class UnifiedSymbolInformation(TypedDict):
|
||||
""" The range that should be selected and revealed when this symbol is being picked, e.g the name of a function.
|
||||
Must be contained by the `range`. """
|
||||
|
||||
body: NotRequired[str]
|
||||
body: NotRequired["SymbolBody"]
|
||||
""" The body of the symbol. """
|
||||
|
||||
children: list[UnifiedSymbolInformation]
|
||||
|
||||
@@ -236,20 +236,20 @@ class TestProjectCreateHelper:
|
||||
|
||||
def test_create_project_helper_returns_config(self, temp_project_dir):
|
||||
"""Test that _create_project returns a ProjectConfig with explicit language."""
|
||||
config = ProjectCommands._create_project(temp_project_dir, "test-project", ("python",))
|
||||
config = ProjectCommands._create_project(temp_project_dir, "test-project", ("python",)).project_config
|
||||
assert isinstance(config, ProjectConfig)
|
||||
assert config.project_name == "test-project"
|
||||
|
||||
def test_create_project_helper_with_auto_detect(self, temp_project_dir_with_python_file):
|
||||
"""Test _create_project with auto-detected language."""
|
||||
config = ProjectCommands._create_project(temp_project_dir_with_python_file, "my-project", ())
|
||||
config = ProjectCommands._create_project(temp_project_dir_with_python_file, "my-project", ()).project_config
|
||||
assert isinstance(config, ProjectConfig)
|
||||
assert config.project_name == "my-project"
|
||||
assert len(config.languages) >= 1
|
||||
|
||||
def test_create_project_helper_with_languages(self, temp_project_dir):
|
||||
"""Test _create_project with language specification."""
|
||||
config = ProjectCommands._create_project(temp_project_dir, None, ("python", "typescript"))
|
||||
config = ProjectCommands._create_project(temp_project_dir, None, ("python", "typescript")).project_config
|
||||
assert isinstance(config, ProjectConfig)
|
||||
assert len(config.languages) >= 1
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def serena_config():
|
||||
)
|
||||
test_projects.append(RegisteredProject.from_project_instance(project))
|
||||
|
||||
config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False, log_level=logging.ERROR)
|
||||
config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR)
|
||||
config.projects = test_projects
|
||||
return config
|
||||
|
||||
|
||||
+64
-14
@@ -2,7 +2,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from serena.symbol import LanguageServerSymbolRetriever, NamePathMatcher
|
||||
from serena.symbol import LanguageServerSymbolRetriever, NamePathComponent, NamePathMatcher
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
@@ -11,17 +11,17 @@ class TestSymbolNameMatching:
|
||||
def _create_assertion_error_message(
|
||||
self,
|
||||
name_path_pattern: str,
|
||||
symbol_name_path_parts: list[str],
|
||||
name_path_components: list[NamePathComponent],
|
||||
is_substring_match: bool,
|
||||
expected_result: bool,
|
||||
actual_result: bool,
|
||||
) -> str:
|
||||
"""Helper to create a detailed error message for assertions."""
|
||||
qnp_repr = "/".join(symbol_name_path_parts)
|
||||
qnp_repr = "/".join(map(str, name_path_components))
|
||||
|
||||
return (
|
||||
f"Pattern '{name_path_pattern}' (substring: {is_substring_match}) vs "
|
||||
f"Qualname parts {symbol_name_path_parts} (as '{qnp_repr}'). "
|
||||
f"Name path components {name_path_components} (as '{qnp_repr}'). "
|
||||
f"Expected: {expected_result}, Got: {actual_result}"
|
||||
)
|
||||
|
||||
@@ -81,7 +81,8 @@ class TestSymbolNameMatching:
|
||||
)
|
||||
def test_match_simple_name(self, name_path_pattern, symbol_name_path_parts, is_substring_match, expected):
|
||||
"""Tests matching for simple names (no '/' in pattern)."""
|
||||
result = NamePathMatcher(name_path_pattern, is_substring_match).matches_components(symbol_name_path_parts, None)
|
||||
symbol_name_path_components = [NamePathComponent(part) for part in symbol_name_path_parts]
|
||||
result = NamePathMatcher(name_path_pattern, is_substring_match).matches_reversed_components(reversed(symbol_name_path_components))
|
||||
error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, is_substring_match, expected, result)
|
||||
assert result == expected, error_msg
|
||||
|
||||
@@ -148,6 +149,13 @@ class TestSymbolNameMatching:
|
||||
),
|
||||
pytest.param("/bar/foo", ["bar"], False, False, id="A: '/bar/foo' does not match ['bar'] (pattern longer)"),
|
||||
pytest.param("/bar/foo", ["baz", "foo"], False, False, id="A: '/bar/foo' does not match ['baz', 'foo'] (first part mismatch)"),
|
||||
pytest.param(
|
||||
"/bar/foo",
|
||||
["baz", "bar", "foo"],
|
||||
False,
|
||||
False,
|
||||
id="A: '/bar/foo' does not match ['baz', 'bar', 'foo'] (only suffix match for abs pattern)",
|
||||
),
|
||||
pytest.param("/bar/foo", ["bar", "baz"], False, False, id="A: '/bar/foo' does not match ['bar', 'baz'] (last part mismatch)"),
|
||||
# Substring matches (is_substring_match=True)
|
||||
pytest.param("/bar/foo", ["bar", "foobar"], True, True, id="A: '/bar/foo' matches ['bar', 'foobar'] as substring"),
|
||||
@@ -161,24 +169,66 @@ class TestSymbolNameMatching:
|
||||
)
|
||||
def test_match_name_path_pattern_path_len_2(self, name_path_pattern, symbol_name_path_parts, is_substring_match, expected):
|
||||
"""Tests matching for qualified names (e.g. 'module/class/func')."""
|
||||
result = NamePathMatcher(name_path_pattern, is_substring_match).matches_components(symbol_name_path_parts, None)
|
||||
symbol_name_path_components = [NamePathComponent(part) for part in symbol_name_path_parts]
|
||||
result = NamePathMatcher(name_path_pattern, is_substring_match).matches_reversed_components(reversed(symbol_name_path_components))
|
||||
error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, is_substring_match, expected, result)
|
||||
assert result == expected, error_msg
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name_path_pattern, symbol_name_path_parts, symbol_overload_idx, expected",
|
||||
"name_path_pattern, symbol_name_path_components, expected",
|
||||
[
|
||||
pytest.param("bar/foo", ["bar", "foo"], 0, True, id="R: 'bar/foo' matches ['bar', 'foo'] with overload_index=0"),
|
||||
pytest.param("bar/foo", ["bar", "foo"], 1, True, id="R: 'bar/foo' matches ['bar', 'foo'] with overload_index=1"),
|
||||
pytest.param("bar/foo[0]", ["bar", "foo"], 0, True, id="R: 'bar/foo[0]' matches ['bar', 'foo'] with overload_index=0"),
|
||||
pytest.param("bar/foo[1]", ["bar", "foo"], 0, False, id="R: 'bar/foo[1]' does not match ['bar', 'foo'] with overload_index=0"),
|
||||
pytest.param(
|
||||
"bar/foo",
|
||||
[NamePathComponent("bar"), NamePathComponent("foo", 0)],
|
||||
True,
|
||||
id="R: 'bar/foo' matches ['bar', 'foo'] with overload_index=0",
|
||||
),
|
||||
pytest.param(
|
||||
"bar/foo",
|
||||
[NamePathComponent("bar"), NamePathComponent("foo", 1)],
|
||||
True,
|
||||
id="R: 'bar/foo' matches ['bar', 'foo'] with overload_index=1",
|
||||
),
|
||||
pytest.param(
|
||||
"bar/foo[0]",
|
||||
[NamePathComponent("bar"), NamePathComponent("foo", 0)],
|
||||
True,
|
||||
id="R: 'bar/foo[0]' matches ['bar', 'foo'] with overload_index=0",
|
||||
),
|
||||
pytest.param(
|
||||
"bar/foo[1]",
|
||||
[NamePathComponent("bar"), NamePathComponent("foo", 0)],
|
||||
False,
|
||||
id="R: 'bar/foo[1]' does not match ['bar', 'foo'] with overload_index=0",
|
||||
),
|
||||
pytest.param(
|
||||
"bar/foo", [NamePathComponent("bar", 0), NamePathComponent("foo")], True, id="R: 'bar/foo' matches ['bar[0]', 'foo']"
|
||||
),
|
||||
pytest.param(
|
||||
"bar/foo", [NamePathComponent("bar", 0), NamePathComponent("foo", 1)], True, id="R: 'bar/foo' matches ['bar[0]', 'foo[1]']"
|
||||
),
|
||||
pytest.param(
|
||||
"bar[0]/foo", [NamePathComponent("bar", 0), NamePathComponent("foo")], True, id="R: 'bar[0]/foo' matches ['bar[0]', 'foo']"
|
||||
),
|
||||
pytest.param(
|
||||
"bar[0]/foo[1]",
|
||||
[NamePathComponent("bar", 0), NamePathComponent("foo", 1)],
|
||||
True,
|
||||
id="R: 'bar[0]/foo[1]' matches ['bar[0]', 'foo[1]']",
|
||||
),
|
||||
pytest.param(
|
||||
"bar[0]/foo[1]",
|
||||
[NamePathComponent("bar", 1), NamePathComponent("foo", 0)],
|
||||
False,
|
||||
id="R: 'bar[0]/foo[1]' does not match ['bar[1]', 'foo[0]']",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_match_name_path_pattern_with_overload_idx(self, name_path_pattern, symbol_name_path_parts, symbol_overload_idx, expected):
|
||||
def test_match_name_path_pattern_with_overload_idx(self, name_path_pattern, symbol_name_path_components, expected):
|
||||
"""Tests matching for qualified names (e.g. 'module/class/func')."""
|
||||
matcher = NamePathMatcher(name_path_pattern, False)
|
||||
result = matcher.matches_components(symbol_name_path_parts, symbol_overload_idx)
|
||||
error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_parts, False, expected, result)
|
||||
result = matcher.matches_reversed_components(reversed(symbol_name_path_components))
|
||||
error_msg = self._create_assertion_error_message(name_path_pattern, symbol_name_path_components, False, expected, result)
|
||||
assert result == expected, error_msg
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ def test_all_tool_parameters_have_type(context):
|
||||
For every tool exposed by Serena, ensure that the generated
|
||||
Open‑AI schema contains a ``type`` entry for each parameter.
|
||||
"""
|
||||
cfg = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False, log_level=logging.ERROR)
|
||||
cfg = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR)
|
||||
registry = ToolRegistry()
|
||||
cfg.included_optional_tools = tuple(registry.get_tool_names_optional())
|
||||
factory = SerenaMCPFactory(context=context)
|
||||
|
||||
@@ -2,7 +2,9 @@ import os
|
||||
|
||||
import pytest
|
||||
|
||||
from serena.symbol import LanguageServerSymbol
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.language_servers.al_language_server import ALLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_utils import SymbolUtils
|
||||
from test.conftest import language_tests_enabled
|
||||
@@ -10,37 +12,127 @@ from test.conftest import language_tests_enabled
|
||||
pytestmark = [pytest.mark.al, pytest.mark.skipif(not language_tests_enabled(Language.AL), reason="AL tests are disabled")]
|
||||
|
||||
|
||||
class TestExtractALDisplayName:
|
||||
"""Tests for the ALLanguageServer._extract_al_display_name method."""
|
||||
|
||||
def test_table_with_quoted_name(self) -> None:
|
||||
"""Test extraction from Table with quoted name."""
|
||||
assert ALLanguageServer._extract_al_display_name('Table 50000 "TEST Customer"') == "TEST Customer"
|
||||
|
||||
def test_page_with_quoted_name(self) -> None:
|
||||
"""Test extraction from Page with quoted name."""
|
||||
assert ALLanguageServer._extract_al_display_name('Page 50001 "TEST Customer Card"') == "TEST Customer Card"
|
||||
|
||||
def test_codeunit_unquoted(self) -> None:
|
||||
"""Test extraction from Codeunit with unquoted name."""
|
||||
assert ALLanguageServer._extract_al_display_name("Codeunit 50000 CustomerMgt") == "CustomerMgt"
|
||||
|
||||
def test_enum_unquoted(self) -> None:
|
||||
"""Test extraction from Enum with unquoted name."""
|
||||
assert ALLanguageServer._extract_al_display_name("Enum 50000 CustomerType") == "CustomerType"
|
||||
|
||||
def test_interface_no_id(self) -> None:
|
||||
"""Test extraction from Interface (no ID)."""
|
||||
assert ALLanguageServer._extract_al_display_name("Interface IPaymentProcessor") == "IPaymentProcessor"
|
||||
|
||||
def test_table_extension(self) -> None:
|
||||
"""Test extraction from TableExtension."""
|
||||
assert ALLanguageServer._extract_al_display_name('TableExtension 50000 "Ext Customer"') == "Ext Customer"
|
||||
|
||||
def test_page_extension(self) -> None:
|
||||
"""Test extraction from PageExtension."""
|
||||
assert ALLanguageServer._extract_al_display_name('PageExtension 50000 "My Page Ext"') == "My Page Ext"
|
||||
|
||||
def test_non_al_object_unchanged(self) -> None:
|
||||
"""Test that non-AL-object names pass through unchanged."""
|
||||
assert ALLanguageServer._extract_al_display_name("fields") == "fields"
|
||||
assert ALLanguageServer._extract_al_display_name("CreateCustomer") == "CreateCustomer"
|
||||
assert ALLanguageServer._extract_al_display_name("Name") == "Name"
|
||||
|
||||
def test_report_with_quoted_name(self) -> None:
|
||||
"""Test extraction from Report."""
|
||||
assert ALLanguageServer._extract_al_display_name('Report 50000 "Sales Invoice"') == "Sales Invoice"
|
||||
|
||||
def test_query_unquoted(self) -> None:
|
||||
"""Test extraction from Query."""
|
||||
assert ALLanguageServer._extract_al_display_name("Query 50000 CustomerQuery") == "CustomerQuery"
|
||||
|
||||
|
||||
@pytest.mark.al
|
||||
class TestALLanguageServer:
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_symbol_names_are_normalized(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that AL symbol names are normalized (metadata stripped)."""
|
||||
file_path = os.path.join("src", "Tables", "Customer.Table.al")
|
||||
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
|
||||
|
||||
_all_symbols, root_symbols = symbols
|
||||
customer_table = None
|
||||
for sym in root_symbols:
|
||||
if sym.get("name") == "TEST Customer":
|
||||
customer_table = sym
|
||||
break
|
||||
|
||||
assert customer_table is not None, "Could not find 'TEST Customer' table symbol (name should be normalized)"
|
||||
# Name should be just "TEST Customer", not "Table 50000 'TEST Customer'"
|
||||
assert customer_table["name"] == "TEST Customer", f"Expected normalized name 'TEST Customer', got '{customer_table['name']}'"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_find_symbol_exact_match(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that find_symbol can match AL symbols by normalized name without substring_matching."""
|
||||
file_path = os.path.join("src", "Tables", "Customer.Table.al")
|
||||
symbols = language_server.request_document_symbols(file_path)
|
||||
|
||||
# Find symbols that match 'TEST Customer' using LanguageServerSymbol.find()
|
||||
for root in symbols.root_symbols:
|
||||
ls_symbol = LanguageServerSymbol(root)
|
||||
matches = ls_symbol.find("TEST Customer", substring_matching=False)
|
||||
if matches:
|
||||
assert len(matches) >= 1, "Should find at least one match for 'TEST Customer'"
|
||||
assert matches[0].name == "TEST Customer", f"Expected 'TEST Customer', got '{matches[0].name}'"
|
||||
return
|
||||
|
||||
pytest.fail("Could not find 'TEST Customer' symbol by exact name match")
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_find_codeunit_exact_match(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test finding a codeunit by its normalized name."""
|
||||
file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al")
|
||||
symbols = language_server.request_document_symbols(file_path)
|
||||
|
||||
for root in symbols.root_symbols:
|
||||
ls_symbol = LanguageServerSymbol(root)
|
||||
matches = ls_symbol.find("CustomerMgt", substring_matching=False)
|
||||
if matches:
|
||||
assert len(matches) >= 1
|
||||
assert matches[0].name == "CustomerMgt"
|
||||
return
|
||||
|
||||
pytest.fail("Could not find 'CustomerMgt' symbol by exact name match")
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_find_symbol(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that AL Language Server can find symbols in the test repository."""
|
||||
"""Test that AL Language Server can find symbols in the test repository with normalized names."""
|
||||
symbols = language_server.request_full_symbol_tree()
|
||||
|
||||
# Check for table symbols - AL returns full object names like 'Table 50000 "TEST Customer"'
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, 'Table 50000 "TEST Customer"'), "TEST Customer table not found in symbol tree"
|
||||
# Check for table symbols - names should be normalized (no "Table 50000" prefix)
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer"), "TEST Customer table not found in symbol tree"
|
||||
|
||||
# Check for page symbols
|
||||
assert SymbolUtils.symbol_tree_contains_name(
|
||||
symbols, 'Page 50001 "TEST Customer Card"'
|
||||
), "TEST Customer Card page not found in symbol tree"
|
||||
assert SymbolUtils.symbol_tree_contains_name(
|
||||
symbols, 'Page 50002 "TEST Customer List"'
|
||||
), "TEST Customer List page not found in symbol tree"
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer Card"), "TEST Customer Card page not found in symbol tree"
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "TEST Customer List"), "TEST Customer List page not found in symbol tree"
|
||||
|
||||
# Check for codeunit symbols
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "Codeunit 50000 CustomerMgt"), "CustomerMgt codeunit not found in symbol tree"
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "CustomerMgt"), "CustomerMgt codeunit not found in symbol tree"
|
||||
assert SymbolUtils.symbol_tree_contains_name(
|
||||
symbols, "Codeunit 50001 PaymentProcessorImpl"
|
||||
symbols, "PaymentProcessorImpl"
|
||||
), "PaymentProcessorImpl codeunit not found in symbol tree"
|
||||
|
||||
# Check for enum symbol
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "Enum 50000 CustomerType"), "CustomerType enum not found in symbol tree"
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "CustomerType"), "CustomerType enum not found in symbol tree"
|
||||
|
||||
# Check for interface symbol
|
||||
assert SymbolUtils.symbol_tree_contains_name(
|
||||
symbols, "Interface IPaymentProcessor"
|
||||
), "IPaymentProcessor interface not found in symbol tree"
|
||||
assert SymbolUtils.symbol_tree_contains_name(symbols, "IPaymentProcessor"), "IPaymentProcessor interface not found in symbol tree"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_find_table_fields(self, language_server: SolidLanguageServer) -> None:
|
||||
@@ -52,7 +144,7 @@ class TestALLanguageServer:
|
||||
customer_table = None
|
||||
_all_symbols, root_symbols = symbols
|
||||
for sym in root_symbols:
|
||||
if "TEST Customer" in sym.get("name", ""):
|
||||
if sym.get("name") == "TEST Customer":
|
||||
customer_table = sym
|
||||
break
|
||||
|
||||
@@ -81,11 +173,11 @@ class TestALLanguageServer:
|
||||
file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al")
|
||||
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
|
||||
|
||||
# Find the codeunit symbol - AL returns 'Codeunit 50000 CustomerMgt'
|
||||
# Find the codeunit symbol - name should be normalized to 'CustomerMgt'
|
||||
codeunit_symbol = None
|
||||
_all_symbols, root_symbols = symbols
|
||||
for sym in root_symbols:
|
||||
if "CustomerMgt" in sym.get("name", ""):
|
||||
if sym.get("name") == "CustomerMgt":
|
||||
codeunit_symbol = sym
|
||||
break
|
||||
|
||||
@@ -95,7 +187,6 @@ class TestALLanguageServer:
|
||||
if "children" in codeunit_symbol:
|
||||
procedure_names = [child.get("name", "") for child in codeunit_symbol.get("children", [])]
|
||||
assert any("CreateCustomer" in name for name in procedure_names), "CreateCustomer procedure not found"
|
||||
# Note: UpdateCustomerBalance doesn't exist in our test repo, check for actual procedures
|
||||
assert any("TestNoSeries" in name for name in procedure_names), "TestNoSeries procedure not found"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
@@ -105,11 +196,11 @@ class TestALLanguageServer:
|
||||
table_file = os.path.join("src", "Tables", "Customer.Table.al")
|
||||
symbols = language_server.request_document_symbols(table_file).get_all_symbols_and_roots()
|
||||
|
||||
# Find the Customer table symbol
|
||||
# Find the Customer table symbol (name is normalized)
|
||||
customer_symbol = None
|
||||
_all_symbols, root_symbols = symbols
|
||||
for sym in root_symbols:
|
||||
if "TEST Customer" in sym.get("name", ""):
|
||||
if sym.get("name") == "TEST Customer":
|
||||
customer_symbol = sym
|
||||
break
|
||||
|
||||
@@ -133,29 +224,335 @@ class TestALLanguageServer:
|
||||
# Get all symbols to verify cross-file visibility
|
||||
symbols = language_server.request_full_symbol_tree()
|
||||
|
||||
# Count how many AL-specific symbols we found
|
||||
al_symbols = []
|
||||
# Count how many AL object symbols we found (names are now normalized)
|
||||
al_object_names = []
|
||||
|
||||
def collect_symbols(syms):
|
||||
def collect_symbols(syms: list) -> None:
|
||||
for sym in syms:
|
||||
if isinstance(sym, dict):
|
||||
name = sym.get("name", "")
|
||||
# Look for AL object names (Table, Page, Codeunit, etc.)
|
||||
if any(keyword in name for keyword in ["Table", "Page", "Codeunit", "Enum", "Interface"]):
|
||||
al_symbols.append(name)
|
||||
# These are normalized names now, so just collect them
|
||||
al_object_names.append(name)
|
||||
if "children" in sym:
|
||||
collect_symbols(sym["children"])
|
||||
|
||||
collect_symbols(symbols)
|
||||
|
||||
# We should find symbols from multiple files
|
||||
assert len(al_symbols) >= 5, f"Expected at least 5 AL object symbols, found {len(al_symbols)}: {al_symbols}"
|
||||
# We should find expected normalized names
|
||||
assert "TEST Customer" in al_object_names, f"TEST Customer not found in: {al_object_names}"
|
||||
assert "CustomerMgt" in al_object_names, f"CustomerMgt not found in: {al_object_names}"
|
||||
assert "CustomerType" in al_object_names, f"CustomerType not found in: {al_object_names}"
|
||||
|
||||
# Verify we have symbols from different AL object types
|
||||
has_table = any("Table" in s for s in al_symbols)
|
||||
has_page = any("Page" in s for s in al_symbols)
|
||||
has_codeunit = any("Codeunit" in s for s in al_symbols)
|
||||
|
||||
assert has_table, f"No Table symbols found in: {al_symbols}"
|
||||
assert has_page, f"No Page symbols found in: {al_symbols}"
|
||||
assert has_codeunit, f"No Codeunit symbols found in: {al_symbols}"
|
||||
@pytest.mark.al
|
||||
class TestALHoverInjection:
|
||||
"""Tests for hover injection of original AL object names with type and ID."""
|
||||
|
||||
def _get_symbol_hover(self, language_server: SolidLanguageServer, file_path: str, symbol_name: str) -> tuple[dict | None, str | None]:
|
||||
"""Helper to get hover info for a symbol by name.
|
||||
|
||||
Returns (hover_info, hover_value) tuple.
|
||||
"""
|
||||
symbols = language_server.request_document_symbols(file_path)
|
||||
for sym in symbols.root_symbols:
|
||||
if sym.get("name") == symbol_name:
|
||||
sel_range = sym.get("selectionRange", {})
|
||||
start = sel_range.get("start", {})
|
||||
line = start.get("line", 0)
|
||||
char = start.get("character", 0)
|
||||
hover = language_server.request_hover(file_path, line, char)
|
||||
if hover and "contents" in hover:
|
||||
return hover, hover["contents"].get("value", "")
|
||||
return hover, None
|
||||
return None, None
|
||||
|
||||
def _get_child_symbol_hover(
|
||||
self, language_server: SolidLanguageServer, file_path: str, parent_name: str, child_name_contains: str
|
||||
) -> tuple[dict | None, str | None]:
|
||||
"""Helper to get hover info for a child symbol.
|
||||
|
||||
Returns (hover_info, hover_value) tuple.
|
||||
"""
|
||||
symbols = language_server.request_document_symbols(file_path)
|
||||
for sym in symbols.root_symbols:
|
||||
if sym.get("name") == parent_name:
|
||||
for child in sym.get("children", []):
|
||||
if child_name_contains in child.get("name", ""):
|
||||
sel_range = child.get("selectionRange", {})
|
||||
start = sel_range.get("start", {})
|
||||
line = start.get("line", 0)
|
||||
char = start.get("character", 0)
|
||||
hover = language_server.request_hover(file_path, line, char)
|
||||
if hover and "contents" in hover:
|
||||
return hover, hover["contents"].get("value", "")
|
||||
return hover, None
|
||||
return None, None
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_table_injects_full_name(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hovering over a Table symbol shows the full object name with ID."""
|
||||
file_path = os.path.join("src", "Tables", "Customer.Table.al")
|
||||
hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer")
|
||||
|
||||
assert hover is not None, "Hover should return a result for Table symbol"
|
||||
assert value is not None, "Hover should have content"
|
||||
assert '**Table 50000 "TEST Customer"**' in value, f"Hover should contain full Table name with ID. Got: {value[:200]}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_page_injects_full_name(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hovering over a Page symbol shows the full object name with ID."""
|
||||
file_path = os.path.join("src", "Pages", "CustomerCard.Page.al")
|
||||
hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer Card")
|
||||
|
||||
assert hover is not None, "Hover should return a result for Page symbol"
|
||||
assert value is not None, "Hover should have content"
|
||||
assert '**Page 50001 "TEST Customer Card"**' in value, f"Hover should contain full Page name with ID. Got: {value[:200]}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_codeunit_injects_full_name(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hovering over a Codeunit symbol shows the full object name with ID."""
|
||||
file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al")
|
||||
hover, value = self._get_symbol_hover(language_server, file_path, "CustomerMgt")
|
||||
|
||||
assert hover is not None, "Hover should return a result for Codeunit symbol"
|
||||
assert value is not None, "Hover should have content"
|
||||
assert "**Codeunit 50000 CustomerMgt**" in value, f"Hover should contain full Codeunit name with ID. Got: {value[:200]}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_enum_injects_full_name(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hovering over an Enum symbol shows the full object name with ID."""
|
||||
file_path = os.path.join("src", "Enums", "CustomerType.Enum.al")
|
||||
hover, value = self._get_symbol_hover(language_server, file_path, "CustomerType")
|
||||
|
||||
assert hover is not None, "Hover should return a result for Enum symbol"
|
||||
assert value is not None, "Hover should have content"
|
||||
assert "**Enum 50000 CustomerType**" in value, f"Hover should contain full Enum name with ID. Got: {value[:200]}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_interface_injects_full_name(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hovering over an Interface symbol shows the full object name (no ID for interfaces)."""
|
||||
file_path = os.path.join("src", "Interfaces", "IPaymentProcessor.Interface.al")
|
||||
hover, value = self._get_symbol_hover(language_server, file_path, "IPaymentProcessor")
|
||||
|
||||
assert hover is not None, "Hover should return a result for Interface symbol"
|
||||
assert value is not None, "Hover should have content"
|
||||
assert "**Interface IPaymentProcessor**" in value, f"Hover should contain full Interface name. Got: {value[:200]}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_procedure_no_injection(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hovering over a procedure does NOT inject object name (procedures are not normalized)."""
|
||||
file_path = os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al")
|
||||
hover, value = self._get_child_symbol_hover(language_server, file_path, "CustomerMgt", "CreateCustomer")
|
||||
|
||||
assert hover is not None, "Hover should return a result for procedure"
|
||||
assert value is not None, "Hover should have content"
|
||||
# Procedure hover should NOT start with ** (no injection)
|
||||
assert not value.startswith("**"), f"Procedure hover should not have injected name. Got: {value[:200]}"
|
||||
# But should contain procedure info
|
||||
assert "CreateCustomer" in value, f"Hover should contain procedure name. Got: {value[:200]}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_field_no_injection(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hovering over a field does NOT inject object name (fields are not normalized)."""
|
||||
file_path = os.path.join("src", "Tables", "Customer.Table.al")
|
||||
symbols = language_server.request_document_symbols(file_path)
|
||||
|
||||
# Navigate to a field: Table -> fields -> specific field
|
||||
for sym in symbols.root_symbols:
|
||||
if sym.get("name") == "TEST Customer":
|
||||
for child in sym.get("children", []):
|
||||
if child.get("name") == "fields":
|
||||
for field in child.get("children", []):
|
||||
if "Name" in field.get("name", ""):
|
||||
sel_range = field.get("selectionRange", {})
|
||||
start = sel_range.get("start", {})
|
||||
line = start.get("line", 0)
|
||||
char = start.get("character", 0)
|
||||
hover = language_server.request_hover(file_path, line, char)
|
||||
|
||||
assert hover is not None, "Hover should return a result for field"
|
||||
value = hover.get("contents", {}).get("value", "")
|
||||
# Field hover should NOT start with ** (no injection)
|
||||
assert not value.startswith("**"), f"Field hover should not have injected name. Got: {value[:200]}"
|
||||
return
|
||||
|
||||
pytest.fail("Could not find a field to test hover on")
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_multiple_objects_correct_injection(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that multiple AL objects each get their correct full name injected."""
|
||||
test_cases = [
|
||||
(os.path.join("src", "Tables", "Customer.Table.al"), "TEST Customer", 'Table 50000 "TEST Customer"'),
|
||||
(os.path.join("src", "Codeunits", "CustomerMgt.Codeunit.al"), "CustomerMgt", "Codeunit 50000 CustomerMgt"),
|
||||
(os.path.join("src", "Enums", "CustomerType.Enum.al"), "CustomerType", "Enum 50000 CustomerType"),
|
||||
]
|
||||
|
||||
for file_path, symbol_name, expected_full_name in test_cases:
|
||||
hover, value = self._get_symbol_hover(language_server, file_path, symbol_name)
|
||||
|
||||
assert hover is not None, f"Hover should return a result for {symbol_name}"
|
||||
assert value is not None, f"Hover should have content for {symbol_name}"
|
||||
assert (
|
||||
f"**{expected_full_name}**" in value
|
||||
), f"Hover for {symbol_name} should contain '{expected_full_name}'. Got: {value[:200]}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_contains_separator_after_injection(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that injected hover has a separator between injected name and original content."""
|
||||
file_path = os.path.join("src", "Tables", "Customer.Table.al")
|
||||
hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer")
|
||||
|
||||
assert hover is not None, "Hover should return a result"
|
||||
assert value is not None, "Hover should have content"
|
||||
# Should have the separator after the bold name
|
||||
assert "---" in value, f"Hover should contain separator. Got: {value[:300]}"
|
||||
# The separator should come after the injected name
|
||||
bold_end = value.find("**", 2) # Find closing **
|
||||
separator_pos = value.find("---")
|
||||
assert separator_pos > bold_end, "Separator should come after the injected name"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_preserves_original_content(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that the original hover content is preserved after the injected name."""
|
||||
file_path = os.path.join("src", "Tables", "Customer.Table.al")
|
||||
hover, value = self._get_symbol_hover(language_server, file_path, "TEST Customer")
|
||||
|
||||
assert hover is not None, "Hover should return a result"
|
||||
assert value is not None, "Hover should have content"
|
||||
# Original AL hover content should still be present (the table structure)
|
||||
assert "```al" in value, f"Hover should contain original AL code block. Got: {value[:500]}"
|
||||
assert 'Table "TEST Customer"' in value, f"Hover should contain original table definition. Got: {value[:500]}"
|
||||
|
||||
|
||||
@pytest.mark.al
|
||||
class TestALPathNormalization:
|
||||
"""Tests for path normalization in hover injection cache."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_with_forward_slash_path(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hover injection works with forward slash paths."""
|
||||
file_path = "src/Tables/Customer.Table.al"
|
||||
symbols = language_server.request_document_symbols(file_path)
|
||||
|
||||
for sym in symbols.root_symbols:
|
||||
if sym.get("name") == "TEST Customer":
|
||||
sel_range = sym.get("selectionRange", {})
|
||||
start = sel_range.get("start", {})
|
||||
line = start.get("line", 0)
|
||||
char = start.get("character", 0)
|
||||
|
||||
hover = language_server.request_hover(file_path, line, char)
|
||||
assert hover is not None, "Hover should return a result"
|
||||
value = hover.get("contents", {}).get("value", "")
|
||||
assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}"
|
||||
return
|
||||
|
||||
pytest.fail("Could not find TEST Customer symbol")
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_with_backslash_path(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hover injection works with backslash paths (Windows style)."""
|
||||
file_path = "src\\Tables\\Customer.Table.al"
|
||||
symbols = language_server.request_document_symbols(file_path)
|
||||
|
||||
for sym in symbols.root_symbols:
|
||||
if sym.get("name") == "TEST Customer":
|
||||
sel_range = sym.get("selectionRange", {})
|
||||
start = sel_range.get("start", {})
|
||||
line = start.get("line", 0)
|
||||
char = start.get("character", 0)
|
||||
|
||||
hover = language_server.request_hover(file_path, line, char)
|
||||
assert hover is not None, "Hover should return a result"
|
||||
value = hover.get("contents", {}).get("value", "")
|
||||
assert '**Table 50000 "TEST Customer"**' in value, f"Hover should have injection. Got: {value[:200]}"
|
||||
return
|
||||
|
||||
pytest.fail("Could not find TEST Customer symbol")
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_with_mixed_path_formats_symbols_backslash_hover_forward(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test hover works when symbols requested with backslash but hover with forward slash."""
|
||||
file_path_backslash = "src\\Tables\\Customer.Table.al"
|
||||
file_path_forward = "src/Tables/Customer.Table.al"
|
||||
|
||||
# Request symbols with backslash path
|
||||
symbols = language_server.request_document_symbols(file_path_backslash)
|
||||
|
||||
for sym in symbols.root_symbols:
|
||||
if sym.get("name") == "TEST Customer":
|
||||
sel_range = sym.get("selectionRange", {})
|
||||
start = sel_range.get("start", {})
|
||||
line = start.get("line", 0)
|
||||
char = start.get("character", 0)
|
||||
|
||||
# Request hover with forward slash path (different format)
|
||||
hover = language_server.request_hover(file_path_forward, line, char)
|
||||
assert hover is not None, "Hover should return a result"
|
||||
value = hover.get("contents", {}).get("value", "")
|
||||
assert (
|
||||
'**Table 50000 "TEST Customer"**' in value
|
||||
), f"Hover injection should work with mixed path formats. Got: {value[:200]}"
|
||||
return
|
||||
|
||||
pytest.fail("Could not find TEST Customer symbol")
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_with_mixed_path_formats_symbols_forward_hover_backslash(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test hover works when symbols requested with forward slash but hover with backslash."""
|
||||
file_path_forward = "src/Tables/Customer.Table.al"
|
||||
file_path_backslash = "src\\Tables\\Customer.Table.al"
|
||||
|
||||
# Request symbols with forward slash path
|
||||
symbols = language_server.request_document_symbols(file_path_forward)
|
||||
|
||||
for sym in symbols.root_symbols:
|
||||
if sym.get("name") == "TEST Customer":
|
||||
sel_range = sym.get("selectionRange", {})
|
||||
start = sel_range.get("start", {})
|
||||
line = start.get("line", 0)
|
||||
char = start.get("character", 0)
|
||||
|
||||
# Request hover with backslash path (different format)
|
||||
hover = language_server.request_hover(file_path_backslash, line, char)
|
||||
assert hover is not None, "Hover should return a result"
|
||||
value = hover.get("contents", {}).get("value", "")
|
||||
assert (
|
||||
'**Table 50000 "TEST Customer"**' in value
|
||||
), f"Hover injection should work with mixed path formats. Got: {value[:200]}"
|
||||
return
|
||||
|
||||
pytest.fail("Could not find TEST Customer symbol")
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.AL], indirect=True)
|
||||
def test_hover_caching_multiple_files_different_path_formats(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test that hover injection cache works correctly across multiple files with different path formats."""
|
||||
test_cases = [
|
||||
("src/Tables/Customer.Table.al", "src\\Tables\\Customer.Table.al", "TEST Customer", 'Table 50000 "TEST Customer"'),
|
||||
(
|
||||
"src\\Codeunits\\CustomerMgt.Codeunit.al",
|
||||
"src/Codeunits/CustomerMgt.Codeunit.al",
|
||||
"CustomerMgt",
|
||||
"Codeunit 50000 CustomerMgt",
|
||||
),
|
||||
]
|
||||
|
||||
for symbols_path, hover_path, symbol_name, expected_injection in test_cases:
|
||||
# Request symbols with one path format
|
||||
symbols = language_server.request_document_symbols(symbols_path)
|
||||
|
||||
for sym in symbols.root_symbols:
|
||||
if sym.get("name") == symbol_name:
|
||||
sel_range = sym.get("selectionRange", {})
|
||||
start = sel_range.get("start", {})
|
||||
line = start.get("line", 0)
|
||||
char = start.get("character", 0)
|
||||
|
||||
# Request hover with different path format
|
||||
hover = language_server.request_hover(hover_path, line, char)
|
||||
assert hover is not None, f"Hover should return a result for {symbol_name}"
|
||||
value = hover.get("contents", {}).get("value", "")
|
||||
assert (
|
||||
f"**{expected_injection}**" in value
|
||||
), f"Hover for {symbol_name} should have injection with mixed paths. Got: {value[:200]}"
|
||||
break
|
||||
@@ -50,7 +50,7 @@ class TestBashLanguageServerBasics:
|
||||
assert greet_user_symbol is not None, "Should find greet_user function"
|
||||
|
||||
if "body" in greet_user_symbol:
|
||||
body = greet_user_symbol["body"]
|
||||
body = greet_user_symbol["body"].get_text()
|
||||
assert "function greet_user()" in body, "Function body should contain function definition"
|
||||
assert "case" in body.lower(), "Function body should contain case statement"
|
||||
|
||||
|
||||
@@ -212,7 +212,8 @@ class TestDartLanguageServer:
|
||||
assert containing_symbol["name"] == "add"
|
||||
assert containing_symbol["kind"] == SymbolKind.Method
|
||||
if "body" in containing_symbol:
|
||||
assert "add" in containing_symbol["body"] or "final result" in containing_symbol["body"]
|
||||
body = containing_symbol["body"].get_text()
|
||||
assert "add" in body or "final result" in body
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.DART], indirect=True)
|
||||
def test_request_containing_symbol_class(self, language_server: SolidLanguageServer) -> None:
|
||||
|
||||
@@ -53,7 +53,7 @@ class TestElixirLanguageServerSymbols:
|
||||
assert containing_symbol["name"] == "def create_user(pid, id, name, email, roles \\\\ [])"
|
||||
assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function
|
||||
if "body" in containing_symbol:
|
||||
assert "def create_user" in containing_symbol["body"]
|
||||
assert "def create_user" in containing_symbol["body"].get_text()
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
|
||||
def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None:
|
||||
|
||||
@@ -52,7 +52,7 @@ class TestErlangLanguageServerSymbols:
|
||||
assert "create_user" in containing_symbol["name"]
|
||||
assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function
|
||||
if "body" in containing_symbol:
|
||||
assert "create_user" in containing_symbol["body"]
|
||||
assert "create_user" in containing_symbol["body"].get_text()
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
|
||||
def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None:
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Unit tests for the Pascal language server auto-update functionality.
|
||||
|
||||
These tests validate the version comparison, checksum verification,
|
||||
and other helper methods without requiring network access or the
|
||||
actual Pascal language server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp.language_servers.pascal_server import PascalLanguageServer
|
||||
|
||||
pytestmark = [pytest.mark.pascal]
|
||||
|
||||
|
||||
class TestVersionNormalization:
|
||||
"""Test version string normalization."""
|
||||
|
||||
def test_normalize_version_with_v_prefix(self) -> None:
|
||||
"""Test that 'v' prefix is stripped."""
|
||||
assert PascalLanguageServer._normalize_version("v1.0.0") == "1.0.0"
|
||||
|
||||
def test_normalize_version_with_capital_v_prefix(self) -> None:
|
||||
"""Test that 'V' prefix is stripped."""
|
||||
assert PascalLanguageServer._normalize_version("V1.0.0") == "1.0.0"
|
||||
|
||||
def test_normalize_version_without_prefix(self) -> None:
|
||||
"""Test version without prefix is unchanged."""
|
||||
assert PascalLanguageServer._normalize_version("1.0.0") == "1.0.0"
|
||||
|
||||
def test_normalize_version_with_whitespace(self) -> None:
|
||||
"""Test that whitespace is stripped."""
|
||||
assert PascalLanguageServer._normalize_version(" v1.0.0 ") == "1.0.0"
|
||||
|
||||
def test_normalize_version_empty(self) -> None:
|
||||
"""Test empty version returns empty string."""
|
||||
assert PascalLanguageServer._normalize_version("") == ""
|
||||
|
||||
def test_normalize_version_none(self) -> None:
|
||||
"""Test None returns empty string."""
|
||||
assert PascalLanguageServer._normalize_version(None) == ""
|
||||
|
||||
|
||||
class TestVersionComparison:
|
||||
"""Test version comparison logic."""
|
||||
|
||||
def test_newer_version_major(self) -> None:
|
||||
"""Test detection of newer major version."""
|
||||
assert PascalLanguageServer._is_newer_version("v2.0.0", "v1.0.0") is True
|
||||
|
||||
def test_newer_version_minor(self) -> None:
|
||||
"""Test detection of newer minor version."""
|
||||
assert PascalLanguageServer._is_newer_version("v1.1.0", "v1.0.0") is True
|
||||
|
||||
def test_newer_version_patch(self) -> None:
|
||||
"""Test detection of newer patch version."""
|
||||
assert PascalLanguageServer._is_newer_version("v1.0.1", "v1.0.0") is True
|
||||
|
||||
def test_same_version(self) -> None:
|
||||
"""Test same version returns False."""
|
||||
assert PascalLanguageServer._is_newer_version("v1.0.0", "v1.0.0") is False
|
||||
|
||||
def test_older_version(self) -> None:
|
||||
"""Test older version returns False."""
|
||||
assert PascalLanguageServer._is_newer_version("v1.0.0", "v2.0.0") is False
|
||||
|
||||
def test_latest_none_returns_false(self) -> None:
|
||||
"""Test None latest version returns False."""
|
||||
assert PascalLanguageServer._is_newer_version(None, "v1.0.0") is False
|
||||
|
||||
def test_local_none_returns_true(self) -> None:
|
||||
"""Test None local version returns True (first install)."""
|
||||
assert PascalLanguageServer._is_newer_version("v1.0.0", None) is True
|
||||
|
||||
def test_both_none_returns_false(self) -> None:
|
||||
"""Test both None returns False."""
|
||||
assert PascalLanguageServer._is_newer_version(None, None) is False
|
||||
|
||||
def test_version_with_different_lengths(self) -> None:
|
||||
"""Test versions with different number of parts."""
|
||||
assert PascalLanguageServer._is_newer_version("v1.0.1", "v1.0") is True
|
||||
assert PascalLanguageServer._is_newer_version("v1.0", "v1.0.1") is False
|
||||
|
||||
def test_version_with_prerelease(self) -> None:
|
||||
"""Test versions with prerelease suffixes."""
|
||||
# Prerelease suffix is ignored, only numeric parts are compared
|
||||
assert PascalLanguageServer._is_newer_version("v1.1.0-beta", "v1.0.0") is True
|
||||
|
||||
|
||||
class TestSHA256Checksum:
|
||||
"""Test SHA256 checksum calculation and verification."""
|
||||
|
||||
def test_calculate_sha256(self) -> None:
|
||||
"""Test SHA256 calculation for a known content."""
|
||||
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
||||
f.write(b"test content")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
result = PascalLanguageServer._calculate_sha256(temp_path)
|
||||
expected = hashlib.sha256(b"test content").hexdigest()
|
||||
assert result == expected
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_verify_checksum_correct(self) -> None:
|
||||
"""Test checksum verification with correct checksum."""
|
||||
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
||||
f.write(b"test content")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
expected = hashlib.sha256(b"test content").hexdigest()
|
||||
assert PascalLanguageServer._verify_checksum(temp_path, expected) is True
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_verify_checksum_incorrect(self) -> None:
|
||||
"""Test checksum verification with incorrect checksum."""
|
||||
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
||||
f.write(b"test content")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
wrong_checksum = "0" * 64
|
||||
assert PascalLanguageServer._verify_checksum(temp_path, wrong_checksum) is False
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_verify_checksum_case_insensitive(self) -> None:
|
||||
"""Test checksum verification is case insensitive."""
|
||||
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
|
||||
f.write(b"test content")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
expected = hashlib.sha256(b"test content").hexdigest().upper()
|
||||
assert PascalLanguageServer._verify_checksum(temp_path, expected) is True
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
class TestTarfileSafety:
|
||||
"""Test tarfile path traversal protection."""
|
||||
|
||||
def test_safe_tar_member_normal_path(self) -> None:
|
||||
"""Test normal path is considered safe."""
|
||||
member = tarfile.TarInfo(name="pasls")
|
||||
assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True
|
||||
|
||||
def test_safe_tar_member_nested_path(self) -> None:
|
||||
"""Test nested path is considered safe."""
|
||||
member = tarfile.TarInfo(name="subdir/pasls")
|
||||
assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True
|
||||
|
||||
def test_unsafe_tar_member_path_traversal(self) -> None:
|
||||
"""Test path traversal is detected."""
|
||||
member = tarfile.TarInfo(name="../etc/passwd")
|
||||
assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is False
|
||||
|
||||
def test_unsafe_tar_member_hidden_traversal(self) -> None:
|
||||
"""Test hidden path traversal in nested path."""
|
||||
member = tarfile.TarInfo(name="subdir/../../etc/passwd")
|
||||
assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is False
|
||||
|
||||
def test_safe_tar_member_similar_name(self) -> None:
|
||||
"""Test path containing '..' in filename (not directory) is safe."""
|
||||
member = tarfile.TarInfo(name="file..name")
|
||||
assert PascalLanguageServer._is_safe_tar_member(member, "/tmp/target") is True
|
||||
|
||||
|
||||
class TestMetadataManagement:
|
||||
"""Test metadata directory and file management."""
|
||||
|
||||
def test_meta_dir_creates_directory(self) -> None:
|
||||
"""Test _meta_dir creates directory if not exists."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
meta_path = PascalLanguageServer._meta_dir(temp_dir)
|
||||
assert os.path.exists(meta_path)
|
||||
assert meta_path == os.path.join(temp_dir, PascalLanguageServer.META_DIR)
|
||||
|
||||
def test_meta_file_returns_correct_path(self) -> None:
|
||||
"""Test _meta_file returns correct path."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
meta_file = PascalLanguageServer._meta_file(temp_dir, "version")
|
||||
expected = os.path.join(temp_dir, PascalLanguageServer.META_DIR, "version")
|
||||
assert meta_file == expected
|
||||
|
||||
|
||||
class TestUpdateCheckTiming:
|
||||
"""Test update check timing logic."""
|
||||
|
||||
def test_should_check_update_no_last_check(self) -> None:
|
||||
"""Test should check when no last_check file exists."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
assert PascalLanguageServer._should_check_update(temp_dir) is True
|
||||
|
||||
def test_should_check_update_recent_check(self) -> None:
|
||||
"""Test should not check when recently checked."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create meta dir and last_check file with current time
|
||||
meta_dir = PascalLanguageServer._meta_dir(temp_dir)
|
||||
last_check_file = os.path.join(meta_dir, "last_check")
|
||||
with open(last_check_file, "w") as f:
|
||||
f.write(str(time.time()))
|
||||
|
||||
assert PascalLanguageServer._should_check_update(temp_dir) is False
|
||||
|
||||
def test_should_check_update_old_check(self) -> None:
|
||||
"""Test should check when last check was > 24 hours ago."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create meta dir and last_check file with old time
|
||||
meta_dir = PascalLanguageServer._meta_dir(temp_dir)
|
||||
last_check_file = os.path.join(meta_dir, "last_check")
|
||||
old_time = time.time() - (PascalLanguageServer.UPDATE_CHECK_INTERVAL + 3600)
|
||||
with open(last_check_file, "w") as f:
|
||||
f.write(str(old_time))
|
||||
|
||||
assert PascalLanguageServer._should_check_update(temp_dir) is True
|
||||
|
||||
def test_update_last_check_creates_file(self) -> None:
|
||||
"""Test _update_last_check creates timestamp file."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
PascalLanguageServer._update_last_check(temp_dir)
|
||||
last_check_file = PascalLanguageServer._meta_file(temp_dir, "last_check")
|
||||
assert os.path.exists(last_check_file)
|
||||
|
||||
with open(last_check_file) as f:
|
||||
timestamp = float(f.read().strip())
|
||||
assert abs(timestamp - time.time()) < 5 # within 5 seconds
|
||||
|
||||
|
||||
class TestVersionPersistence:
|
||||
"""Test local version persistence."""
|
||||
|
||||
def test_save_and_get_local_version(self) -> None:
|
||||
"""Test saving and retrieving local version."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
PascalLanguageServer._save_local_version(temp_dir, "v1.0.0")
|
||||
version = PascalLanguageServer._get_local_version(temp_dir)
|
||||
assert version == "v1.0.0"
|
||||
|
||||
def test_get_local_version_not_exists(self) -> None:
|
||||
"""Test getting version when file doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
version = PascalLanguageServer._get_local_version(temp_dir)
|
||||
assert version is None
|
||||
@@ -34,7 +34,7 @@ class TestLanguageServerSymbols:
|
||||
assert containing_symbol["name"] == "create_user"
|
||||
assert containing_symbol["kind"] == SymbolKind.Method
|
||||
if "body" in containing_symbol:
|
||||
assert containing_symbol["body"].strip().startswith("def create_user(self")
|
||||
assert containing_symbol["body"].get_text().strip().startswith("def create_user(self")
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True)
|
||||
def test_references_to_variables(self, language_server: SolidLanguageServer) -> None:
|
||||
@@ -463,7 +463,6 @@ class TestLanguageServerSymbols:
|
||||
for ref in language_server.request_referencing_symbols(file_path, 4, 6, include_imports=False, include_file_symbols=True)
|
||||
]
|
||||
assert {ref["kind"] for ref in references_to_typing} == {SymbolKind.File}
|
||||
assert {ref["body"] for ref in references_to_typing} == {""}
|
||||
|
||||
# now include bodies
|
||||
references_to_typing = [
|
||||
|
||||
@@ -53,7 +53,7 @@ class TestRubyLanguageServerSymbols:
|
||||
|
||||
# Verify body content if available
|
||||
if "body" in containing_symbol:
|
||||
body = containing_symbol["body"]
|
||||
body = containing_symbol["body"].get_text()
|
||||
assert "def create_user" in body, "Method body should contain method definition"
|
||||
assert len(body.strip()) > 0, "Method body should not be empty"
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from solidlsp.ls import SolidLanguageServer
|
||||
|
||||
|
||||
class DummyLanguageServer(SolidLanguageServer):
|
||||
def _start_server(self) -> None:
|
||||
raise AssertionError("Not used in this test")
|
||||
|
||||
|
||||
def test_request_rename_symbol_edit_opens_file_before_rename(tmp_path) -> None:
|
||||
(tmp_path / "index.ts").write_text("export const x = 1;\n", encoding="utf-8")
|
||||
|
||||
events: list[str] = []
|
||||
|
||||
notify = MagicMock()
|
||||
notify.did_open_text_document.side_effect = lambda *_args, **_kwargs: events.append("didOpen")
|
||||
notify.did_close_text_document.side_effect = lambda *_args, **_kwargs: events.append("didClose")
|
||||
|
||||
send = MagicMock()
|
||||
send.rename.side_effect = lambda *_args, **_kwargs: events.append("rename")
|
||||
|
||||
server = MagicMock()
|
||||
server.notify = notify
|
||||
server.send = send
|
||||
|
||||
language_server = object.__new__(DummyLanguageServer)
|
||||
language_server.repository_root_path = str(tmp_path)
|
||||
language_server.server_started = True
|
||||
language_server.open_file_buffers = {}
|
||||
language_server._encoding = "utf-8"
|
||||
language_server.language_id = "typescript"
|
||||
language_server.server = server
|
||||
|
||||
result = language_server.request_rename_symbol_edit(
|
||||
relative_file_path="index.ts",
|
||||
line=0,
|
||||
column=0,
|
||||
new_name="y",
|
||||
)
|
||||
assert result is None
|
||||
assert events == ["didOpen", "rename", "didClose"]
|
||||
@@ -137,7 +137,7 @@ class TestTomlLanguageServerBasics:
|
||||
# Check that body exists and contains expected content
|
||||
# Note: Taplo includes the section header in the body
|
||||
assert "body" in package_symbol, "'package' symbol should have body"
|
||||
package_body = package_symbol["body"]
|
||||
package_body = package_symbol["body"].get_text()
|
||||
assert 'name = "test_project"' in package_body, "Body should contain 'name' field"
|
||||
assert 'version = "0.1.0"' in package_body, "Body should contain 'version' field"
|
||||
assert 'edition = "2021"' in package_body, "Body should contain 'edition' field"
|
||||
@@ -146,7 +146,7 @@ class TestTomlLanguageServerBasics:
|
||||
deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None)
|
||||
assert deps_symbol is not None, "Should find 'dependencies' symbol"
|
||||
assert "body" in deps_symbol, "'dependencies' symbol should have body"
|
||||
deps_body = deps_symbol["body"]
|
||||
deps_body = deps_symbol["body"].get_text()
|
||||
assert "serde" in deps_body, "Body should contain serde dependency"
|
||||
assert "tokio" in deps_body, "Body should contain tokio dependency"
|
||||
|
||||
@@ -160,7 +160,7 @@ class TestTomlLanguageServerBasics:
|
||||
)
|
||||
assert features_symbol is not None, "Should find top-level 'features' table symbol"
|
||||
assert "body" in features_symbol, "'features' symbol should have body"
|
||||
features_body = features_symbol["body"]
|
||||
features_body = features_symbol["body"].get_text()
|
||||
assert "default" in features_body, "Body should contain 'default' feature"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.TOML], indirect=True)
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestTomlEdgeCases:
|
||||
assert endpoint_symbol is not None
|
||||
|
||||
if "body" in endpoint_symbol:
|
||||
body = endpoint_symbol["body"]
|
||||
body = endpoint_symbol["body"].get_text()
|
||||
# Body should contain the inline table syntax
|
||||
assert "url" in body or "version" in body, f"Body should contain inline table contents, got: {body}"
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class TestVueSymbolRetrieval:
|
||||
|
||||
# Verify the body is included if available
|
||||
if "body" in containing_symbol:
|
||||
assert "handleDigit" in containing_symbol["body"], "Function body should contain function name"
|
||||
assert "handleDigit" in containing_symbol["body"].get_text(), "Function body should contain function name"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_request_containing_symbol_computed_property(self, language_server: SolidLanguageServer) -> None:
|
||||
|
||||
@@ -124,7 +124,7 @@ class TestYAMLLanguageServerBasics:
|
||||
|
||||
# Check that body exists and contains expected content
|
||||
assert "body" in app_symbol, "'app' symbol should have body"
|
||||
app_body = app_symbol["body"]
|
||||
app_body = app_symbol["body"].get_text()
|
||||
assert "app:" in app_body, "Body should start with 'app:'"
|
||||
assert "name: test-application" in app_body, "Body should contain 'name' field"
|
||||
assert "version: 1.0.0" in app_body, "Body should contain 'version' field"
|
||||
@@ -135,13 +135,13 @@ class TestYAMLLanguageServerBasics:
|
||||
name_symbols = [s for s in all_symbols if s.get("name") == "name" and "body" in s]
|
||||
assert len(name_symbols) > 0, "Should find 'name' symbols with bodies"
|
||||
# At least one should contain "test-application"
|
||||
assert any("test-application" in s["body"] for s in name_symbols), "Should find name with test-application"
|
||||
assert any("test-application" in s["body"].get_text() for s in name_symbols), "Should find name with test-application"
|
||||
|
||||
# Find the database symbol and check its body
|
||||
database_symbol = next((s for s in all_symbols if s.get("name") == "database"), None)
|
||||
assert database_symbol is not None, "Should find 'database' symbol"
|
||||
assert "body" in database_symbol, "'database' symbol should have body"
|
||||
db_body = database_symbol["body"]
|
||||
db_body = database_symbol["body"].get_text()
|
||||
assert "database:" in db_body, "Body should start with 'database:'"
|
||||
assert "host: localhost" in db_body, "Body should contain host configuration"
|
||||
assert "port: 5432" in db_body, "Body should contain port configuration"
|
||||
|
||||
@@ -627,7 +627,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219, upload-time = "2025-06-05T16:10:10.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383, upload-time = "2025-06-05T16:38:51.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422, upload-time = "2025-06-05T16:41:35.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375, upload-time = "2025-06-05T16:48:18.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627, upload-time = "2025-06-05T16:13:02.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502, upload-time = "2025-06-05T16:12:49.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498, upload-time = "2025-06-05T16:36:46.598Z" },
|
||||
@@ -1990,7 +1989,7 @@ requires-dist = [
|
||||
{ name = "agno", marker = "extra == 'agno'", specifier = ">=2.2.1" },
|
||||
{ name = "anthropic", specifier = ">=0.54.0" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
|
||||
{ name = "black", extras = ["jupyter"], marker = "extra == 'dev'", specifier = ">=23.7.0" },
|
||||
{ name = "black", extras = ["jupyter"], marker = "extra == 'dev'", specifier = ">=23.7.0,<26" },
|
||||
{ name = "docstring-parser", specifier = ">=0.16" },
|
||||
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||
{ name = "flask", specifier = ">=3.0.0" },
|
||||
|
||||
Reference in new issue
Block a user