Merge remote-tracking branch 'origin/main' into query-project-tool

Conflicts:
	src/serena/agent.py
	src/serena/project.py
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-03-05 19:08:10 +01:00
commit 8f3a31768e
66 files changed
+3090 -1351

No files matched your search

+22 -2
View File
@@ -139,7 +139,10 @@ jobs:
- name: Setup .NET SDK (for F# and C# languages)
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
dotnet-version: '10.0.x'
- name: List .NET runtimes
shell: bash
run: dotnet --list-runtimes
- name: Install Terraform
uses: hashicorp/setup-terraform@v3
with:
@@ -196,6 +199,23 @@ jobs:
- name: Install Ruby language server
shell: bash
run: gem install ruby-lsp
- name: Install OCaml and opam
uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: ${{ runner.os == 'Windows' && '4.14' || '5.3.x' }}
dune-cache: true
opam-repositories: |
${{ runner.os == 'Windows' && 'opam-repository-mingw: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset' || '' }}
default: https://github.com/ocaml/opam-repository.git
- name: Install OCaml packages
shell: bash
run: |
if [ "$RUNNER_OS" = "Windows" ]; then
opam install -y dune ocaml-lsp-server
else
# Require ocaml-lsp-server >= 1.23.0 for cross-file reference support
opam install -y dune 'ocaml-lsp-server>=1.23.0'
fi
- name: Install R
uses: r-lib/actions/setup-r@v2
with:
@@ -394,7 +414,7 @@ jobs:
- name: Install Regal (Rego Language Server)
shell: bash
run: |
REGAL_VERSION="0.36.1"
REGAL_VERSION="0.39.0"
if [[ "${{ runner.os }}" == "Linux" ]]; then
if [[ "$(uname -m)" == "x86_64" ]]; then
+7 -1
View File
@@ -226,6 +226,9 @@ tmp/
# Exception: Don't ignore Nix test repository lib directory (contains source code)
!/test/resources/repos/nix/test_repo/lib
# Exception: Don't ignore OCaml test repository lib directory (contains source code)
!/test/resources/repos/ocaml/test_repo/lib
# Exception: Don't ignore Julia test repository lib directory (contains source code)
!/test/resources/repos/julia/test_repo/lib
@@ -233,6 +236,9 @@ tmp/
/test/resources/repos/swift/test_repo/.build
/test/resources/repos/swift/test_repo/.swiftpm
# OCaml
/test/resources/repos/ocaml/test_repo/_build
# Elm
/test/resources/repos/elm/test_repo/.elm/
/test/resources/repos/elm/test_repo/elm-stuff/
@@ -254,4 +260,4 @@ cabal.project.local*
.ghc.environment.*
zz-misc/
vue-implementation/
vue-implementation/
+1
View File
@@ -43,6 +43,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* Language support:
* **Add support for OCaml** via ocaml-lsp-server with cross-file reference support on OCaml 5.2+ (requires opam; see [setup guide](docs/03-special-guides/ocaml_setup_guide_for_serena.md))
* **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+)
* **Add support for Fortran** via fortls language server (requires `pip install fortls`)
* **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md))
+3
View File
@@ -11,6 +11,9 @@ The following types of contributions can be submitted directly via pull requests
For other changes, please open an issue first to discuss your ideas with the maintainers.
When submitting a PR, ensure a well-defined scope.
Every PR should cover a single logical change or a set of closely related changes.
### Adding Support for a New Language Server
See the corresponding [memory](.serena/memories/adding_new_language_support_guide.md).
+5 -6
View File
@@ -82,7 +82,7 @@ that implement the language server protocol (LSP).
The underlying language servers are typically open-source projects (like Serena) or at least freely available for use.
With Serena's LSP library, we provide **support for over 30 programming languages**, including
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig.
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
> [!IMPORTANT]
> Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details.
@@ -90,14 +90,13 @@ AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Groovy (pa
### The Serena JetBrains Plugin
As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/)
leverages the powerful code analysis capabilities of your JetBrains IDE.
The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs,
including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, CLion, and others.
Only Rider is not supported.
leverages the powerful code analysis capabilities of your JetBrains IDE.
The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs,
including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, and potentially others (Rider and CLion are unsupported though).
<a href="https://plugins.jetbrains.com/plugin/28946-serena/"><img src="docs/_static/images/jetbrains-marketplace-button.png"></a>
The plugin offers the most robust and most powerful Serena experience.
The plugin offers the most robust and most powerful Serena experience.
See our [documentation page](https://oraios.github.io/serena/02-usage/025_jetbrains_plugin.html) for further details and instructions.
## Quick Start
+11 -4
View File
@@ -30,7 +30,7 @@ Some languages require additional installations or setup steps, as noted.
* **AL**
* **Bash**
* **C#**
(by default, uses the Roslyn language server (language `csharp`), requires `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)) on Windows;
(by default, uses the Roslyn language server (language `csharp`), requiring [.NET v10+](https://dotnet.microsoft.com/en-us/download/dotnet) and, on Windows, `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5));
set language to `csharp_omnisharp` to use OmiSharp instead)
* **C/C++**
(by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`);
@@ -45,15 +45,19 @@ Some languages require additional installations or setup steps, as noted.
* **Erlang**
(requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang)
* **F#**
(requires .NET SDK 8.0+; uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment)
(requires [.NET v8.0+](https://dotnet.microsoft.com/en-us/download/dotnet); uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment)
* **Fortran**
(requires installation of fortls: `pip install fortls`)
* **Go**
(requires installation of `gopls`)
* **Groovy**
(requires local groovy-language-server.jar setup via `GROOVY_LS_JAR_PATH` or configuration)
* **Haskell**
* **Haskell**
(automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects)
* **HLSL / GLSL / WGSL**
(uses [shader-language-server](https://github.com/antaalt/shader-sense) (language `hlsl`); automatically downloaded;
on macOS, requires Rust toolchain for building from source;
note: reference search is not supported by this language server)
* **Java**
* **JavaScript**
* **Julia**
@@ -64,6 +68,8 @@ Some languages require additional installations or setup steps, as noted.
(must explicitly enable language `markdown`, primarily useful for documentation-heavy projects)
* **Nix**
(requires nixd installation)
* **OCaml**
(requires opam and ocaml-lsp-server to be installed manually; see the [OCaml Setup Guide](../03-special-guides/ocaml_setup_guide_for_serena.md))
* **Pascal**
(uses Pascal/Lazarus, which is automatically downloaded; set `PP` and `FPCDIR` environment variables for source navigation)
* **Perl**
@@ -96,7 +102,8 @@ see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena
As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/)
leverages the powerful code analysis capabilities of JetBrains IDEs.
The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs,
including IntelliJ IDEA, PyCharm, Android Studio, AppCode, WebStorm, PhpStorm, RubyMine, GoLand, AppCode, CLion, and others.
including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, and potentially others
(Rider and CLion are unsupported though).
When using the plugin, Serena connects to an instance of your JetBrains IDE via the plugin. For users who already
work in a JetBrains IDE, this means Serena seamlessly integrates with the IDE instance you typically have open anyway,
+4 -14
View File
@@ -103,22 +103,12 @@ By default, Serena will perform an **onboarding process** when
it is started for the first time for a project.
The goal of the onboarding is for Serena to get familiar with the project
and to store memories, which it can then draw upon in future interactions.
If an LLM should fail to complete the onboarding and does not actually write the
respective memories to disk, you may need to ask it to do so explicitly.
The onboarding will usually read a lot of content from the project, thus filling
up the context. It can therefore be advisable to switch to another conversation
once the onboarding is complete.
After the onboarding, we recommend that you have a quick look at the memories and,
if necessary, edit them or add additional ones.
In general, **memories** provide a way for Serena to store and retrieve
information about the project, relevant conventions, and other relevant aspects.
**Memories** are files stored in `.serena/memories/` in the project directory,
which the agent can choose to read in subsequent interactions.
Feel free to read and adjust them as needed; you can also add new ones manually.
Every file in the `.serena/memories/` directory is a memory file.
Whenever Serena starts working on a project, the list of memories is
provided, and the agent can decide to read them.
We found that memories can significantly improve the user experience with Serena.
For more information on this, including how to manage
or disable these features, see [Memories & Onboarding](045_memories).
## Preparing Your Project
+84
View File
@@ -0,0 +1,84 @@
# Memories & Onboarding
Serena provides the functionality of a fully featured agent, and a useful aspect of this is Serena's memory system.
Despite its simplicity, we received positive feedback from many users who tend to combine it with their
agent's internal memory management (e.g., `AGENTS.md` files).
## Memories
Memories are simple, human-readable Markdown files that both you and
your agent can create, read, and edit.
Serena differentiates between
* **project-specific memories**, which are stored in the `.serena/memories/` directory within your project folder, and
* **global memories**, which are shared across all projects and, by default, are stored in `~/.serena/memories/global/`
The LLM is informed about the existence of memories and instructed to read them when appropriate,
inferring appropriateness from the file name.
When the agent starts working on a project, it receives the list of available memories.
The agent should be instructed to update memories by the user when appropriate.
### Organizing Memories
Memories can be organized into **topics** by using `/` in the memory name (e.g. `modules/frontend`).
The structure is mapped to the file system, where topics correspond to subdirectories.
The `list_memories` tool can filter by topic, allowing the agent to explore even large numbers of memories in a structured way.
(global-memories)=
### Global Memories
Global memories use the top-level topic `global`, i.e. whenever a memory name starts with `global/`,
it is stored in the global memories directory and is shared across all projects.
By default, deletion and editing of global memories is allowed.
If you want to primarily manage such memories yourself and protect them from accidental modification by the agent,
set `edit_global_memories: False` in Serena's [global configuration](050_configuration).
Since global memories are not versioned alongside your project files,
it can be helpful to track global memories with git (i.e. to make `~/.serena/memories/` a git repository)
in order to have a history of changes and the possibility to revert them if needed.
### Manually Editing Memories
You may edit memories directly in the file system, using your preferred text editor or IDE.
Alternatively, access them via the [Serena Dashboard](060_dashboard), which provides a graphical interface for
viewing, creating, editing, and deleting memories while Serena is running.
(onboarding)=
## Onboarding
By default, Serena performs an **onboarding process** when it encounters a project
for the first time (i.e., when no project memories exist yet).
The goal of the onboarding is for Serena to get familiar with the project —
its structure, build system, testing setup, and other essential aspects —
and to store this knowledge as memories for future interactions.
In further project activations, Serena will check whether onboarding was already
performed by looking for existing project memories and will skip the onboarding
process if memories are found.
### How Onboarding Works
1. When a project is activated, Serena checks whether onboarding was already
performed (by checking if any memories exist).
2. If no memories are found, Serena triggers the onboarding process, which
reads key files and directories to understand the project.
3. The gathered information is written into project-specific memory files (see above).
### Tips for Onboarding
- **Context usage**: The onboarding process will read a lot of content from the project,
filling up the context window. It is therefore advisable to **switch to a new conversation**
once the onboarding is complete.
- **LLM failures**: If an LLM fails to complete the onboarding and does not actually
write the respective memories to disk, you may need to ask it to do so explicitly.
- **Review the results**: After onboarding, we recommend having a quick look at the
generated memories and editing them or adding new ones as needed.
## Disabling Memories and Onboarding
If you do not require the functionality described in this section, you can selectively disable it.
* To disable all memory related tools (including onboarding), adding `no-memories` to the `base_modes`
in Serena's [global configuration](050_configuration).
* Similarly, to disable only onboarding, add `no-onboarding` to the `base_modes`.
@@ -0,0 +1,88 @@
# OCaml Setup Guide for Serena
This guide explains how to set up an OCaml project so that Serena can provide code intelligence via ocaml-lsp-server (ocamllsp).
Unlike some other languages, Serena does not download the OCaml language server automatically. You must install it yourself via opam, as OCaml tooling is compiled from source against your specific environment.
---
## Prerequisites
Install the following on your system and ensure they are available on `PATH`:
- **opam** (OCaml package manager)
- macOS: `brew install opam`
- Ubuntu/Debian: `sudo apt install opam`
- Fedora: `sudo dnf install opam`
- Other: https://opam.ocaml.org/doc/Install.html
- **OCaml compiler** (via opam)
- OCaml < 5.1 or >= 5.1.1 (OCaml 5.1.0 is **not supported** by ocaml-lsp-server)
- Recommended: OCaml 4.14.x (stable) or 5.2+ (for cross-file references)
- **ocaml-lsp-server** (via opam)
- **dune** (build system, via opam)
---
## Installation
1. Initialize opam if you haven't already:
```bash
opam init
eval $(opam env)
```
2. Create an opam switch with a compatible OCaml version:
```bash
# For cross-file reference support (recommended)
opam switch create serena-ocaml ocaml-base-compiler.5.2.1
eval $(opam env)
# Or for stable OCaml 4.14.x
opam switch create serena-ocaml ocaml-base-compiler.4.14.2
eval $(opam env)
```
3. Install the language server and build tools:
```bash
opam install ocaml-lsp-server dune
```
4. Verify the installation:
```bash
opam exec -- ocamllsp --version
opam exec -- ocaml -version
```
---
## Cross-File References
Cross-file reference support (finding all usages of a symbol across your project) requires:
- OCaml >= 5.2
- ocaml-lsp-server >= 1.23.0
- dune >= 3.16.0
When these requirements are met, Serena automatically builds the cross-file index during startup via `dune build @ocaml-index`. Without these versions, references are limited to the current file.
---
## Using Serena with OCaml
- Serena automatically detects OCaml files (`*.ml`, `*.mli`) and Reason files (`*.re`, `*.rei`).
- The language server is started via `opam exec -- ocamllsp`, so your opam environment must be configured.
- Ensure your project builds successfully with `dune build` before using Serena for best results.
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| "opam not found" | Install opam and add it to PATH |
| "OCaml 5.1.0 is incompatible" | Create a new switch: `opam switch create <name> ocaml-base-compiler.5.2.1` |
| "ocaml-lsp-server not found" | `opam install ocaml-lsp-server` |
| Cross-file refs not working | Ensure OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0; run `dune build` first |
| Stale index | Rebuild with `dune build @ocaml-index` |
---
## Reference
- opam: https://opam.ocaml.org
- ocaml-lsp-server: https://github.com/ocaml/ocaml-lsp
- Project-wide occurrences: https://discuss.ocaml.org/t/ann-project-wide-occurrences-in-merlin-and-lsp/14847
+4 -2
View File
@@ -61,7 +61,7 @@ dev = [
"poethepoet>=0.20.0",
"pytest>=8.0.2",
"pytest-xdist>=3.5.0",
"ruff>=0.0.285",
"ruff==0.12.5",
"toml-sort>=0.24.2",
"types-pyyaml>=6.0.12.20241230",
"syrupy>=4.9.1",
@@ -310,6 +310,7 @@ markers = [
"nix: language server running for Nix",
"dart: language server running for Dart",
"erlang: language server running for Erlang",
"ocaml: language server running for OCaml and Reason",
"scala: language server running for Scala",
"al: language server running for AL (Microsoft Dynamics 365 Business Central)",
"fsharp: language server running for F#",
@@ -326,11 +327,12 @@ markers = [
"toml: language server running for TOML",
"matlab: language server running for MATLAB (requires MATLAB R2021b+)",
"systemverilog: language server running for SystemVerilog (uses verible-verilog-ls)",
"hlsl: language server running for HLSL shaders (uses shader-language-server)",
]
[tool.codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = '.git*,*.svg,*.lock,*.min.*'
check-hidden = true
# ignore-regex = ''
ignore-regex = '\.\w+'
ignore-words-list = 'paket'
+33 -10
View File
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Optional, TypeVar
from sensai.util import logging
from sensai.util.logging import LogTime
from sensai.util.string import TextBuilder, list_string
from interprompt.jinja_template import JinjaTemplate
from serena import serena_version
@@ -24,11 +25,12 @@ from serena.config.serena_config import (
NamedToolInclusionDefinition,
RegisteredProject,
SerenaConfig,
SerenaPaths,
ToolInclusionDefinition,
)
from serena.dashboard import SerenaDashboardAPI
from serena.ls_manager import LanguageServerManager
from serena.project import Project
from serena.ls_manager import LanguageServerManager, LanguageServerManagerInitialisationError
from serena.project import MemoriesManager, Project
from serena.prompt_factory import SerenaPromptFactory
from serena.task_executor import TaskExecutor
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, OpenDashboardTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry
@@ -253,6 +255,7 @@ class SerenaAgent:
# project-specific instances, which will be initialized upon project activation
self._active_project: Project | None = None
self._lsp_init_error: LanguageServerManagerInitialisationError | None = None
# determine registered project to be activated (if any)
registered_project_to_activate: RegisteredProject | None = (
@@ -344,11 +347,10 @@ class SerenaAgent:
self._mode_overrides = modes
if project is not None:
try:
self.activate_project_from_path_or_name(project, update_active_modes=True, update_active_tools=False)
self.activate_project_from_path_or_name(project, update_active_modes=False, update_active_tools=False)
except Exception as e:
log.error(f"Error activating project '{project}' at startup: {e}", exc_info=e)
else:
self._update_active_modes()
self._update_active_modes()
# determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see),
self._base_toolset = self._create_base_toolset(
@@ -475,13 +477,26 @@ class SerenaAgent:
def get_language_server_manager_or_raise(self) -> LanguageServerManager:
language_server_manager = self.get_language_server_manager()
if language_server_manager is None:
raise Exception(
"The language server manager is not initialized, indicating a problem during project activation. "
"Inform the user, telling them to inspect Serena's logs in order to determine the issue. "
"IMPORTANT: Wait for further instructions before you continue!"
msg = TextBuilder("The language server manager is not initialized, indicating a problem during project initialisation.")
if self._lsp_init_error is not None:
msg.with_text(str(self._lsp_init_error))
msg.with_text("For details, please check the logs. " + self.get_log_inspection_instructions())
msg.with_text(
"IMPORTANT: Stop, do not attempt workarounds. Inform the user and wait for further instructions before you continue!"
)
raise Exception(msg.build())
return language_server_manager
def get_log_inspection_instructions(self) -> str:
if self.serena_config.web_dashboard:
return f"Live logs can be inspected via the dashboard at {self.get_dashboard_url()}"
else:
log_path = SerenaPaths().last_returned_log_file_path
if log_path is not None:
return f"Find the current log file here: f{log_path}"
else:
return "Unfortunately, logs are not available. We recommend enabling the web dashboard/logging in general."
def get_context(self) -> SerenaAgentContext:
return self._context
@@ -588,12 +603,15 @@ class SerenaAgent:
def create_system_prompt(self) -> str:
available_tools = self._active_tools
available_markers = available_tools.tool_marker_names
global_memory_names = MemoriesManager.list_global_memories()
global_memories_list = list_string(global_memory_names) if global_memory_names else ""
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.get_active_modes()],
available_tools=available_tools.tool_names,
available_markers=available_markers,
global_memories_list=global_memories_list,
)
# If a project is active at startup, append its activation message
@@ -800,7 +818,12 @@ class SerenaAgent:
"""
Starts/resets the language server manager for the current project
"""
self.get_active_project_or_raise().create_language_server_manager()
try:
self._lsp_init_error = None
self.get_active_project_or_raise().create_language_server_manager()
except LanguageServerManagerInitialisationError as e:
self._lsp_init_error = e
raise
def add_language(self, language: Language) -> None:
"""
+7 -6
View File
@@ -806,6 +806,9 @@ class ProjectCommands(AutoRegisteringGroup):
logging.configure(level=logging.INFO)
project_path = os.path.abspath(project)
serena_config = SerenaConfig.from_config_file()
serena_config.language_backend = LanguageBackend.LSP
serena_config.gui_log_window = False
serena_config.web_dashboard = False
proj = Project.load(project_path, serena_config=serena_config)
# Create log file with timestamp
@@ -820,9 +823,7 @@ class ProjectCommands(AutoRegisteringGroup):
try:
# Create SerenaAgent with dashboard disabled
log.info("Creating SerenaAgent with disabled dashboard...")
serena_config = SerenaConfig.from_config_file()
serena_config.gui_log_window = False
serena_config.web_dashboard = False
agent = SerenaAgent(project=project_path, serena_config=serena_config)
log.info("SerenaAgent created successfully")
@@ -856,7 +857,7 @@ class ProjectCommands(AutoRegisteringGroup):
# Test 1: Get symbols overview
log.info("Testing GetSymbolsOverviewTool on file: %s", target_file)
overview_data = agent.execute_task(lambda: overview_tool.get_symbol_overview(target_file))
log.info("GetSymbolsOverviewTool returned %d symbols", len(overview_data))
log.info(f"GetSymbolsOverviewTool returned: {overview_data}")
if not overview_data:
log.error("No symbols found in file %s", target_file)
@@ -877,8 +878,8 @@ class ProjectCommands(AutoRegisteringGroup):
selected_symbol = overview_data[0]
log.info("No class or function found, using first available symbol")
symbol_name = selected_symbol.get("name_path", "unknown")
symbol_kind = selected_symbol.get("kind", "unknown")
symbol_name = selected_symbol["name"]
symbol_kind = selected_symbol["kind"]
log.info("Using symbol for testing: %s (kind: %s)", symbol_name, symbol_kind)
# Test 2: FindSymbolTool
+16 -2
View File
@@ -85,6 +85,17 @@ class SerenaPaths:
"""
file containing the ID of the last read news snippet
"""
global_memories_path = Path(os.path.join(self.serena_user_home_dir, "memories", "global"))
global_memories_path.mkdir(parents=True, exist_ok=True)
self.global_memories_path = global_memories_path
"""
directory where global memories are stored, i.e. memories that are available across all projects
"""
self.last_returned_log_file_path: str | None = None
"""
the path to the last log file returned by `get_next_log_file_path`. If this is not None, the logs
are currently being written to this file
"""
def get_next_log_file_path(self, prefix: str) -> str:
"""
@@ -93,7 +104,8 @@ class SerenaPaths:
"""
log_dir = os.path.join(self.serena_user_home_dir, "logs", datetime.now().strftime("%Y-%m-%d"))
os.makedirs(log_dir, exist_ok=True)
return os.path.join(log_dir, prefix + "_" + datetime_tag() + ".txt")
self.last_returned_log_file_path = os.path.join(log_dir, prefix + "_" + datetime_tag() + f"_{os.getpid()}" + ".txt")
return self.last_returned_log_file_path
# TODO: Paths from constants.py should be moved here
@@ -549,6 +561,9 @@ class SerenaConfig(SharedConfig):
"""List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **.
These patterns are merged additively with each project's own ignored_paths."""
edit_global_memories: bool = True
"""Whether global memories are allowed to be deleted or edited."""
# settings with overridden defaults
default_modes: Sequence[str] | None = ("interactive", "editing")
symbol_info_budget: float = 10.0
@@ -559,7 +574,6 @@ class SerenaConfig(SharedConfig):
If the budget is exceeded, Serena stops issuing further requests and returns partial info results.
0 disables the budget (no early stopping). Negative values are invalid.
"""
# *** fields that are NOT mapped to/from the configuration file ***
_loaded_commented_yaml: CommentedMap | None = None
+31 -4
View File
@@ -89,6 +89,11 @@ class RequestDeleteMemory(BaseModel):
memory_name: str
class RequestRenameMemory(BaseModel):
old_name: str
new_name: str
class ResponseGetSerenaConfig(BaseModel):
content: str
@@ -269,6 +274,18 @@ class SerenaDashboardAPI:
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/rename_memory", methods=["POST"])
def rename_memory() -> dict[str, str]:
request_data = request.get_json()
if not request_data:
return {"status": "error", "message": "No data provided"}
request_rename_memory = RequestRenameMemory.model_validate(request_data)
try:
result_message = self._rename_memory(request_rename_memory)
return {"status": "success", "message": result_message}
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/get_serena_config", methods=["GET"])
def get_serena_config() -> dict[str, Any]:
try:
@@ -556,8 +573,7 @@ class SerenaDashboardAPI:
project = self._agent.get_active_project()
if project is None:
raise ValueError("No active project")
project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content)
project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content, is_tool_context=False)
self._agent.execute_task(run, logged=True, name="SaveMemory")
@@ -566,11 +582,22 @@ class SerenaDashboardAPI:
project = self._agent.get_active_project()
if project is None:
raise ValueError("No active project")
project.memories_manager.delete_memory(request_delete_memory.memory_name)
project.memories_manager.delete_memory(request_delete_memory.memory_name, is_tool_context=False)
self._agent.execute_task(run, logged=True, name="DeleteMemory")
def _rename_memory(self, request_rename_memory: RequestRenameMemory) -> str:
def run() -> str:
project = self._agent.get_active_project()
if project is None:
raise ValueError("No active project")
return project.memories_manager.move_memory(
request_rename_memory.old_name, request_rename_memory.new_name, is_tool_context=False
)
return self._agent.execute_task(run, logged=True, name="RenameMemory")
def _get_serena_config(self) -> ResponseGetSerenaConfig:
config_path = self._agent.serena_config.config_file_path
if config_path is None or not os.path.exists(config_path):
@@ -33,6 +33,12 @@ class PromptFactory(PromptFactoryBase):
return self._render_prompt("prepare_for_new_conversation", locals())
def create_system_prompt(
self, *, available_markers: Any, available_tools: Any, context_system_prompt: Any, mode_system_prompts: Any
self,
*,
available_markers: Any,
available_tools: Any,
context_system_prompt: Any,
global_memories_list: Any,
mode_system_prompts: Any,
) -> str:
return self._render_prompt("system_prompt", locals())
+18 -6
View File
@@ -14,6 +14,11 @@ from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class LanguageServerManagerInitialisationError(Exception):
def __init__(self, message: str):
super().__init__(message)
class LanguageServerFactory:
def __init__(
self,
@@ -122,14 +127,14 @@ class LanguageServerManager:
# If any server failed to start up, raise an exception and stop all started language servers.
# We intentionally fail fast here. The user's intention is to work with all the specified languages,
# so if any of them is not available, it is better to fail immediately and bring the failure to the
# so if any of them is not available, it is better to make symbolic tool calls fail, bringing the issue to the
# user's attention instead of silently continuing with a subset of the language servers and potentially
# causing suboptimal agent behaviour.
if exceptions:
for ls in language_servers.values():
ls.stop()
failure_messages = "\n".join([f"{lang.value}: {e}" for lang, e in exceptions.items()])
raise Exception(f"Failed to start language servers:\n{failure_messages}")
raise LanguageServerManagerInitialisationError(f"Failed to start {len(exceptions)} language server(s):\n{failure_messages}")
return LanguageServerManager(language_servers, factory)
@@ -142,16 +147,20 @@ class LanguageServerManager:
ls = self.restart_language_server(ls.language)
return ls
def _get_suitable_language_server(self, relative_path: str) -> SolidLanguageServer | None:
""":param relative_path: relative path to a file"""
for candidate in self._language_servers.values():
if not candidate.is_ignored_path(relative_path, ignore_unsupported_files=True):
return candidate
return None
def get_language_server(self, relative_path: str) -> SolidLanguageServer:
""":param relative_path: relative path to a file"""
ls: SolidLanguageServer | None = None
if len(self._language_servers) > 1:
if os.path.isdir(relative_path):
raise ValueError(f"Expected a file path, but got a directory: {relative_path}")
for candidate in self._language_servers.values():
if not candidate.is_ignored_path(relative_path, ignore_unsupported_files=True):
ls = candidate
break
ls = self._get_suitable_language_server(relative_path)
if ls is None:
ls = self._default_language_server
return self._ensure_functional_ls(ls)
@@ -236,3 +245,6 @@ class LanguageServerManager:
for ls in self.iter_language_servers():
if ls.is_running():
ls.save_cache()
def has_suitable_ls_for_file(self, relative_file_path: str) -> bool:
return self._get_suitable_language_server(relative_file_path) is not None
+116 -33
View File
@@ -1,9 +1,10 @@
import json
import logging
import os
import shutil
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal
import pathspec
from sensai.util.logging import LogTime
@@ -11,12 +12,13 @@ from sensai.util.string import ToStringMixin
from serena.config.serena_config import (
ProjectConfig,
SerenaPaths,
get_serena_managed_in_project_dir,
)
from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME
from serena.ls_manager import LanguageServerFactory, LanguageServerManager
from serena.util.file_system import GitignoreParser, match_path
from serena.util.text_utils import MatchedConsecutiveLines, search_files
from serena.util.text_utils import ContentReplacer, MatchedConsecutiveLines, search_files
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import FileUtils
@@ -28,26 +30,59 @@ log = logging.getLogger(__name__)
class MemoriesManager:
def __init__(self, project_root: str):
self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories"
self._memory_dir.mkdir(parents=True, exist_ok=True)
GLOBAL_TOPIC = "global"
_global_memory_dir = SerenaPaths().global_memories_path
def __init__(self, project_root: str, global_memory_tool_write_access: bool = False):
"""
:param project_root: the project's root directory
:param global_memory_tool_write_access: whether to allow writing global memories in tool execution contexts
"""
self._project_memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories"
self._project_memory_dir.mkdir(parents=True, exist_ok=True)
self._global_memory_tool_write_access = global_memory_tool_write_access
self._encoding = SERENA_FILE_ENCODING
def _is_global(self, name: str) -> bool:
return name == self.GLOBAL_TOPIC or name.startswith(self.GLOBAL_TOPIC + "/")
def get_memory_file_path(self, name: str) -> Path:
# Strip .md extension if present
name = name.replace(".md", "")
# Split by "/" to handle subdirectories
if self._is_global(name):
if name == self.GLOBAL_TOPIC:
raise ValueError(
f'Bare "{self.GLOBAL_TOPIC}" is not a valid memory name. '
f'Use "{self.GLOBAL_TOPIC}/<name>" to address a global memory.'
)
# Strip "global/" prefix and resolve against global dir
sub_name = name[len(self.GLOBAL_TOPIC) + 1 :]
parts = sub_name.split("/")
filename = f"{parts[-1]}.md"
if len(parts) > 1:
subdir = self._global_memory_dir / "/".join(parts[:-1])
subdir.mkdir(parents=True, exist_ok=True)
return subdir / filename
return self._global_memory_dir / filename
# Project-local memory
parts = name.split("/")
filename = f"{parts[-1]}.md"
if len(parts) > 1:
# Create subdirectory path
subdir = self._memory_dir / "/".join(parts[:-1])
subdir = self._project_memory_dir / "/".join(parts[:-1])
subdir.mkdir(parents=True, exist_ok=True)
return subdir / filename
return self._memory_dir / filename
return self._project_memory_dir / filename
def _check_write_access(self, name: str, is_tool_context: bool) -> None:
# in tool context, global memory write access can be disabled
if is_tool_context:
if self._is_global(name) and not self._global_memory_tool_write_access:
raise PermissionError(f"Writing to global memories is not allowed (attempted to write to '{name}')")
def load_memory(self, name: str) -> str:
memory_file_path = self.get_memory_file_path(name)
@@ -56,47 +91,70 @@ class MemoriesManager:
with open(memory_file_path, encoding=self._encoding) as f:
return f.read()
def save_memory(self, name: str, content: str) -> str:
def save_memory(self, name: str, content: str, is_tool_context: bool) -> str:
self._check_write_access(name, is_tool_context)
memory_file_path = self.get_memory_file_path(name)
with open(memory_file_path, "w", encoding=self._encoding) as f:
f.write(content)
return f"Memory {name} written."
@staticmethod
def _list_memories(search_dir: Path, base_dir: Path, prefix: str = "") -> list[str]:
if not search_dir.exists():
return []
results = []
for md_file in search_dir.rglob("*.md"):
rel = str(md_file.relative_to(base_dir).with_suffix("")).replace(os.sep, "/")
results.append(prefix + rel)
return results
@classmethod
def list_global_memories(cls, subtopic: str = "") -> list[str]:
dir_path = cls._global_memory_dir
if subtopic:
dir_path = dir_path / subtopic.replace("/", os.sep)
return cls._list_memories(dir_path, cls._global_memory_dir, cls.GLOBAL_TOPIC + "/")
def list_project_memories(self, topic: str = "") -> list[str]:
dir_path = self._project_memory_dir
if topic:
dir_path = dir_path / topic.replace("/", os.sep)
return self._list_memories(dir_path, self._project_memory_dir)
def list_memories(self, topic: str = "") -> list[str]:
"""
List memories, optionally filtered by topic.
Lists all memories, optionally filtered by topic.
If the topic is omitted, both global and project-specific memories are returned.
"""
memories = []
memories: list[str]
if topic:
# Only list memories in specified subdirectory
search_dir = self._memory_dir / topic.replace("/", os.sep)
if not search_dir.exists():
return []
if self._is_global(topic):
topic_parts = topic.split("/")
subtopic = "/".join(topic_parts[1:])
memories = self.list_global_memories(subtopic=subtopic)
else:
memories = self.list_project_memories(topic=topic)
else:
search_dir = self._memory_dir
memories = self.list_project_memories() + self.list_global_memories()
# Recursively find all .md files
for md_file in search_dir.rglob("*.md"):
# Calculate relative path as memory name
rel_path = md_file.relative_to(self._memory_dir)
name = str(rel_path.with_suffix("")).replace(os.sep, "/")
memories.append(name)
# Sort alphabetically by name
return sorted(memories)
def delete_memory(self, name: str) -> str:
def delete_memory(self, name: str, is_tool_context: bool) -> str:
self._check_write_access(name, is_tool_context)
memory_file_path = self.get_memory_file_path(name)
if not memory_file_path.exists():
return f"Memory {name} not found."
memory_file_path.unlink()
return f"Memory {name} deleted."
def rename_memory(self, old_name: str, new_name: str) -> str:
def move_memory(self, old_name: str, new_name: str, is_tool_context: bool) -> str:
"""
Rename or move a memory file.
Moving between global and project scope (e.g. "global/foo" -> "bar") is supported.
"""
self._check_write_access(new_name, is_tool_context)
old_path = self.get_memory_file_path(old_name)
new_path = self.get_memory_file_path(new_name)
@@ -105,13 +163,35 @@ class MemoriesManager:
if new_path.exists():
raise FileExistsError(f"Memory {new_name} already exists.")
# Ensure target directory exists
new_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(old_path, new_path)
# Move/rename the file
old_path.rename(new_path)
return f"Memory renamed from {old_name} to {new_name}."
def edit_memory(
self, name: str, needle: str, repl: str, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool, is_tool_context: bool
) -> str:
"""
Edit a memory by replacing content matching a pattern.
:param name: the memory name
:param needle: the string or regex to search for
:param repl: the replacement string
:param mode: "literal" or "regex"
:param allow_multiple_occurrences:
"""
self._check_write_access(name, is_tool_context)
memory_file_path = self.get_memory_file_path(name)
if not memory_file_path.exists():
raise FileNotFoundError(f"Memory {name} not found.")
with open(memory_file_path, encoding=self._encoding) as f:
original_content = f.read()
replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences)
updated_content = replacer.replace(original_content, needle, repl)
with open(memory_file_path, "w", encoding=self._encoding) as f:
f.write(updated_content)
return f"Memory {name} edited successfully."
class Project(ToStringMixin):
def __init__(
@@ -126,7 +206,10 @@ class Project(ToStringMixin):
self.project_root = project_root
self.project_config = project_config
self._serena_config = serena_config
self.memories_manager = MemoriesManager(project_root)
global_memory_write_access = serena_config.edit_global_memories if serena_config else False
self.memories_manager = MemoriesManager(project_root, global_memory_write_access)
self.language_server_manager: LanguageServerManager | None = None
self._is_newly_created = is_newly_created
@@ -221,10 +304,10 @@ class Project(ToStringMixin):
msg = f"The project with name '{self.project_name}' at {self.project_root} is activated."
languages_str = ", ".join([lang.value for lang in self.project_config.languages])
msg += f"\nProgramming languages: {languages_str}; file encoding: {self.project_config.encoding}"
memories = self.memories_manager.list_memories()
if memories:
project_memories = self.memories_manager.list_project_memories()
if project_memories:
msg += (
f"\nAvailable project memories: {json.dumps(memories)}\n"
f"\nAvailable project memories: {json.dumps(project_memories)}\n"
+ "Use the `read_memory` tool to read these memories later if they are relevant to the task."
)
if self.project_config.initial_prompt:
@@ -6,6 +6,7 @@ excluded_tools:
- read_memory
- delete_memory
- edit_memory
- rename_memory
- list_memories
- onboarding
- check_onboarding_performed
@@ -35,10 +35,13 @@ prompts:
You can understand relationships between symbols by using the `find_referencing_symbols` tool.
{% endif %}
{% if 'read_memory' in available_tools %}
{% if 'read_memory' in available_tools -%}
You generally have access to memories and it may be useful for you to read them.
You infer whether memories are relevant based on their names.
{% endif %}
{% if global_memories_list -%}
The following global (not project-specific) memories are available to you: {{ global_memories_list }}
{%- endif -%}
{%- endif %}
The context and modes of operation are described below. These determine how to interact with your user
and which kinds of interactions are expected of you.
@@ -1308,6 +1308,42 @@ body {
gap: 8px;
}
/* Memory Rename Styles */
.memory-rename-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
opacity: 0.5;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.memory-rename-btn:hover {
opacity: 1;
background-color: var(--border-color);
}
.memory-rename-input {
font-size: inherit;
font-weight: inherit;
font-family: inherit;
color: var(--text-primary);
background: transparent;
border: none;
border-bottom: 1px solid var(--btn-primary);
outline: none;
padding: 0;
flex: 1;
min-width: 200px;
max-width: 80%;
}
/* Log Action Buttons (Save, Copy, Clear) */
.log-action-buttons {
position: absolute;
+74 -3
View File
@@ -307,6 +307,8 @@ class Dashboard {
this.$modalCloseRemove = $('.modal-close-remove');
this.$editMemoryModal = $('#edit-memory-modal');
this.$editMemoryName = $('#edit-memory-name');
this.$editMemoryRenameBtn = $('#edit-memory-rename-btn');
this.$editMemoryRenameInput = $('#edit-memory-rename-input');
this.$editMemoryContent = $('#edit-memory-content');
this.$editMemorySaveBtn = $('#edit-memory-save-btn');
this.$editMemoryCancelBtn = $('#edit-memory-cancel-btn');
@@ -365,6 +367,19 @@ class Dashboard {
this.$editMemoryCancelBtn.click(this.closeEditMemoryModal.bind(this));
this.$modalCloseEditMemory.click(this.closeEditMemoryModal.bind(this));
this.$editMemoryContent.on('input', this.trackMemoryChanges.bind(this));
this.$editMemoryRenameBtn.click(this.startMemoryRename.bind(this));
this.$editMemoryRenameInput.keydown(function (e) {
if (e.which === 13) { // Enter key
e.preventDefault();
self.commitMemoryRename();
} else if (e.which === 27) { // Escape key
e.preventDefault();
self.cancelMemoryRename();
}
});
this.$editMemoryRenameInput.on('blur', function () {
self.cancelMemoryRename();
});
this.$deleteMemoryOkBtn.click(this.confirmDeleteMemoryOk.bind(this));
this.$deleteMemoryCancelBtn.click(this.closeDeleteMemoryModal.bind(this));
this.$modalCloseDeleteMemory.click(this.closeDeleteMemoryModal.bind(this));
@@ -1887,6 +1902,62 @@ class Dashboard {
});
}
startMemoryRename() {
this.$editMemoryName.hide();
this.$editMemoryRenameBtn.hide();
this.$editMemoryRenameInput.val(this.currentMemoryName).show().focus().select();
}
cancelMemoryRename() {
this.$editMemoryRenameInput.hide();
this.$editMemoryName.show();
this.$editMemoryRenameBtn.show();
}
commitMemoryRename() {
const newName = this.$editMemoryRenameInput.val().trim();
const oldName = this.currentMemoryName;
// If name unchanged, just cancel
if (!newName || newName === oldName) {
this.cancelMemoryRename();
return;
}
// Validate memory name (alphanumeric, underscores, and slashes for subdirectories)
if (!/^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)*$/.test(newName)) {
alert('Memory name can only contain letters, numbers, underscores, and "/" for subdirectories (e.g., "topic/memory_name")');
this.$editMemoryRenameInput.focus();
return;
}
const self = this;
this.$editMemoryRenameInput.prop('disabled', true);
$.ajax({
url: '/rename_memory', type: 'POST', contentType: 'application/json', data: JSON.stringify({
old_name: oldName, new_name: newName
}), success: function (response) {
if (response.status === 'success') {
self.currentMemoryName = newName;
self.$editMemoryName.text(newName);
self.cancelMemoryRename();
// Reload config to reflect the rename in the memory list
self.loadConfigOverview();
} else {
alert('Error: ' + response.message);
self.$editMemoryRenameInput.focus();
}
}, error: function (xhr, status, error) {
console.error('Error renaming memory:', error);
alert('Error renaming memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error));
self.$editMemoryRenameInput.focus();
}, complete: function () {
self.$editMemoryRenameInput.prop('disabled', false);
}
});
}
confirmDeleteMemory(memoryName) {
// Set memory name to delete
this.memoryToDelete = memoryName;
@@ -1960,9 +2031,9 @@ class Dashboard {
return;
}
// Validate memory name (alphanumeric and underscores only)
if (!/^[a-zA-Z0-9_]+$/.test(memoryName)) {
alert('Memory name can only contain letters, numbers, and underscores');
// Validate memory name (alphanumeric, underscores, and slashes for subdirectories)
if (!/^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)*$/.test(memoryName)) {
alert('Memory name can only contain letters, numbers, underscores, and "/" for subdirectories (e.g., "topic/memory_name")');
return;
}
+14 -3
View File
@@ -275,7 +275,17 @@
<div id="edit-memory-modal" class="modal" style="display:none">
<div class="modal-content modal-content-large">
<span class="modal-close modal-close-edit-memory">&times;</span>
<h3 style="margin-bottom: 20px;">Memory: <span id="edit-memory-name"></span></h3>
<h3 style="margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
Memory:
<span id="edit-memory-name" class="memory-name-display"></span>
<span id="edit-memory-rename-btn" class="memory-rename-btn" title="Rename memory">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
</span>
<input type="text" id="edit-memory-rename-input" class="memory-rename-input" style="display: none;">
</h3>
<textarea id="edit-memory-content" class="memory-editor" rows="20"
style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-primary); color: var(--text-primary); font-family: 'Courier New', monospace; font-size: 13px; resize: vertical;"></textarea>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
@@ -312,13 +322,14 @@
</p>
<p style="margin: 15px 0; color: var(--text-muted); font-size: 13px; line-height: 1.5;">
Memory names should be descriptive and use underscores instead of spaces (e.g., "api_architecture",
"testing_guidelines").
"testing_guidelines"). Use "/" to organize memories into subdirectories (e.g., "architecture/api_design"),
use the "global/" prefix to write memories that are shared across projects (e.g., "global/java/style_guide").
</p>
<div style="margin: 20px 0;">
<label for="create-memory-name-input" style="display: block; margin-bottom: 8px; font-weight: 500;">Memory
Name:</label>
<input type="text" id="create-memory-name-input" class="memory-name-input"
placeholder="e.g., project_overview"
placeholder="e.g., project_overview or topic/memory_name"
style="width: 100%; padding: 8px 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--bg-primary); color: var(--text-primary); font-size: 14px;">
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
@@ -0,0 +1,12 @@
<div class="news-item">
<h3>Nested and Global Memories</h3>
<p class="date">March 03, 2026</p>
<p>
Serena's memory system has been significantly extended in functionality.
It keeps the simplicity that made it popular but now supports structuring memories
into topics and sharing them across projects.
</p>
<p>
For more detailed information, see the <a href="https://oraios.github.io/serena/02-usage/045_memories.html">documentation</a>.
</p>
</div>
@@ -68,6 +68,12 @@ ls_specific_settings: {}
# These patterns are merged additively with each project's own ignored_paths.
ignored_paths: []
# whether global memories (the "global" topic) are allowed to be deleted or edited by the agent.
# Global memories are stored in ~/.serena/memories/global/ and shared across all projects.
# Set to false to protect global memories from accidental modification by agents.
# Note: We recommend tracking global memories with git.
edit_global_memories: True
# timeout, in seconds, after which tool executions are terminated
tool_timeout: 240
+3
View File
@@ -699,6 +699,9 @@ class LanguageServerSymbolRetriever:
def get_root_path(self) -> str:
return self._ls_manager.get_root_path()
def can_analyze_file(self, relative_file_path: str) -> bool:
return self._ls_manager.has_suitable_ls_for_file(relative_file_path)
def get_language_server(self, relative_path: str) -> SolidLanguageServer:
""":param relative_path: relative path to a file"""
return self._ls_manager.get_language_server(relative_path)
+2 -4
View File
@@ -192,10 +192,8 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
If mode is "regex", the string can contain backreferences to matched groups in the needle regex,
specified using the syntax $!1, $!2, etc. for groups 1, 2, etc.
:param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted.
:param allow_multiple_occurrences: if True, the regex may match multiple occurrences in the file
and all of them will be replaced.
If this is set to False and the regex matches multiple occurrences, an error will be returned
(and you may retry with a revised, more specific regex).
:param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences.
If false and multiple occurrences are found, an error will be returned
"""
return self.replace_content(
relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True
+33 -16
View File
@@ -1,17 +1,20 @@
from typing import Literal
from serena.tools import ReplaceContentTool, Tool, ToolMarkerCanEdit
from serena.tools import Tool, ToolMarkerCanEdit
class WriteMemoryTool(Tool, ToolMarkerCanEdit):
"""
Writes a named memory (for future reference) to Serena's project-specific memory store.
Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
The memory name should be meaningful.
"""
def apply(self, memory_name: str, content: str, max_chars: int = -1) -> str:
"""
Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
Write information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
The memory name should be meaningful and can include "/" to organize into topics (e.g., "auth/login/logic").
If explicitly instructed, use the "global/" prefix for writing a memory that is shared across projects
(e.g., "global/java/style_guide")
:param max_chars: the maximum number of characters to write. By default, determined by the config,
change only if instructed to do so.
@@ -24,28 +27,33 @@ class WriteMemoryTool(Tool, ToolMarkerCanEdit):
f"Content for {memory_name} is too long. Max length is {max_chars} characters. " + "Please make the content shorter."
)
return self.memories_manager.save_memory(memory_name, content)
return self.memories_manager.save_memory(memory_name, content, is_tool_context=True)
class ReadMemoryTool(Tool):
"""
Reads the memory with the given name from Serena's project-specific memory store.
Read the content of a memory file. This tool should only be used if the information
is relevant to the current task. You can infer whether the information
is relevant from the memory file name.
You should not read the same memory file multiple times in the same conversation.
"""
def apply(self, memory_name: str) -> str:
"""
Read the content of a memory. Should only be used if the information
is relevant to the current task, with relevance inferred from the memory name.
You should not read the same memory file multiple times in the same conversation.
Reads the contents of a memory. Should only be used if the information
is likely to be relevant to the current task, inferring relevance from the memory name.
"""
return self.memories_manager.load_memory(memory_name)
class ListMemoriesTool(Tool):
"""
Lists memories in Serena's project-specific memory store.
List available memories. Any memory can be read using the `read_memory` tool.
"""
def list_memories(self, topic: str = "") -> list[str]:
return self.memories_manager.list_memories(topic)
def apply(self, topic: str = "") -> str:
"""
List available memories, optionally filtered by topic.
@@ -55,35 +63,44 @@ class ListMemoriesTool(Tool):
class DeleteMemoryTool(Tool, ToolMarkerCanEdit):
"""
Deletes a memory from Serena's project-specific memory store.
Delete a memory file. Should only happen if a user asks for it explicitly,
for example by saying that the information retrieved from a memory file is no longer correct
or no longer relevant for the project.
"""
def apply(self, memory_name: str) -> str:
"""
Delete a memory, only call if instructed explicitly or permission was granted by the user.
"""
return self.memories_manager.delete_memory(memory_name)
return self.memories_manager.delete_memory(memory_name, is_tool_context=True)
class RenameMemoryTool(Tool, ToolMarkerCanEdit):
"""
Renames or moves a memory in Serena's project-specific memory store.
Renames or moves a memory. Moving between project and global scope is supported
(e.g., renaming "global/foo" to "bar" moves it from global to project scope).
"""
def apply(self, old_name: str, new_name: str) -> str:
"""
Rename or move a memory, use "/" in the name to organize into topics.
The "global" topic should only be used if explicitly instructed.
"""
return self.memories_manager.rename_memory(old_name, new_name)
return self.memories_manager.move_memory(old_name, new_name, is_tool_context=True)
class EditMemoryTool(Tool, ToolMarkerCanEdit):
"""
Replaces content matching a regular expression in a memory.
"""
def apply(
self,
memory_name: str,
needle: str,
repl: str,
mode: Literal["literal", "regex"],
allow_multiple_occurrences: bool = False,
) -> str:
r"""
Replaces content matching a regular expression in a memory.
@@ -95,7 +112,7 @@ class EditMemoryTool(Tool, ToolMarkerCanEdit):
with flags DOTALL and MULTILINE enabled).
:param repl: the replacement string (verbatim).
:param mode: either "literal" or "regex", specifying how the `needle` parameter is to be interpreted.
:param allow_multiple_occurrences: whether to allow matching and replacing multiple occurrences.
If false and multiple occurrences are found, an error will be returned.
"""
replace_content_tool = self.agent.get_tool(ReplaceContentTool)
rel_path = self.memories_manager.get_memory_file_path(memory_name).relative_to(self.get_project_root())
return replace_content_tool.replace_content(str(rel_path), needle, repl, mode=mode, require_not_ignored=False)
return self.memories_manager.edit_memory(memory_name, needle, repl, mode, allow_multiple_occurrences, is_tool_context=True)
+4
View File
@@ -68,6 +68,10 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
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}. ")
if not symbol_retriever.can_analyze_file(relative_path):
raise ValueError(
f"Cannot extract symbols from file {relative_path}. Active languages: {[l.value for l in self.agent.get_active_lsp_languages()]}"
)
symbols = symbol_retriever.get_symbol_overview(relative_path)[relative_path]
+2 -1
View File
@@ -391,7 +391,8 @@ tool_packages = ["serena.tools"]
class ToolRegistry:
def __init__(self) -> None:
self._tool_dict: dict[str, RegisteredTool] = {}
for cls in iter_subclasses(Tool):
inclusion_predicate = lambda c: "apply" in c.__dict__ # include only concrete tool classes that implement apply
for cls in iter_subclasses(Tool, inclusion_predicate=inclusion_predicate):
if not any(cls.__module__.startswith(pkg) for pkg in tool_packages):
continue
is_optional = issubclass(cls, ToolMarkerOptional)
+12 -15
View File
@@ -2,7 +2,6 @@
Tools supporting the general workflow of the agent
"""
import json
import platform
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional
@@ -18,23 +17,21 @@ class CheckOnboardingPerformedTool(Tool):
Checks whether project onboarding was already performed.
You should always call this tool before beginning to actually work on the project/after activating a project.
"""
from .memory_tools import ListMemoriesTool
list_memories_tool = self.agent.get_tool(ListMemoriesTool)
memories = json.loads(list_memories_tool.apply())
if len(memories) == 0:
return (
project_memories = self.memories_manager.list_project_memories()
if len(project_memories) == 0:
msg = (
"Onboarding not performed yet (no memories available). "
+ "You should perform onboarding by calling the `onboarding` tool before proceeding with the task."
"You should perform onboarding by calling the `onboarding` tool before proceeding with the task. "
)
else:
return f"""The onboarding was already performed, below is the list of available memories.
Do not read them immediately, just remember that they exist and that you can read them later, if it is necessary
for the current task.
Some memories may be based on previous conversations, others may be general for the current project.
You should be able to tell which one you need based on the name of the memory.
{memories}"""
# Not reporting the list of memories here, as they were already reported at project activation
# (with the system prompt if the project was activated at startup)
msg = (
f"Onboarding was already performed: {len(project_memories)} project memories are available. "
"Consider reading memories if they appear relevant to the task at hand."
)
msg += " If you have not read the 'Serena Instructions Manual', do so now."
return msg
class OnboardingTool(Tool):
+156
View File
@@ -0,0 +1,156 @@
import logging
import platform
import re
import shutil
import subprocess
import urllib
from pathlib import Path
from serena.util.version import Version
from solidlsp.ls_exceptions import SolidLSPException
log = logging.getLogger(__name__)
class DotNETUtil:
def __init__(self, required_version: str, allow_higher_version: bool = True):
"""
:param required_version: the required .NET runtime version specified as a string (e.g. "10.0" for .NET 10.0)
:param allow_higher_version: whether to allow higher versions than the required version
"""
self._system_dotnet = shutil.which("dotnet")
self._required_version_str = required_version
self._required_version_components = [int(c) for c in required_version.split(".")]
self._allow_higher_version = allow_higher_version
self._installed_versions = self._determine_installed_versions()
def _determine_installed_versions(self) -> list[Version]:
if self._system_dotnet:
try:
result = subprocess.run([self._system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
version_strings = re.findall(r"Microsoft.NETCore.App\s+([^\s]+)", result.stdout)
log.info("Installed .NET runtime versions: %s", version_strings)
return [Version(v) for v in version_strings]
except:
log.warning("Failed to run 'dotnet --list-runtimes' to check .NET version; assuming no installed .NET versions")
return []
else:
log.info("Found no `dotnet` on system PATH; assuming no installed .NET versions")
return []
def is_required_version_available(self) -> bool:
"""
Checks whether the required .NET runtime version is installed and raises an exception if not.
:param required_version_components: the required .NET runtime version specified as a list of integers representing the version components (e.g., [6, 1] for .NET 6.1)
:param allow_higher_version: whether to allow higher versions than the required version (e.g., if True, .NET 7.0 would satisfy a requirement of .NET 6.1)
"""
required_version_str = ".".join(str(c) for c in self._required_version_components)
for v in self._installed_versions:
if self._allow_higher_version:
if v.is_at_least(*self._required_version_components):
log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str} or higher")
return True
else:
if v.is_equal(*self._required_version_components):
log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str}")
return True
return False
def get_dotnet_path_or_raise(self) -> str:
"""
Returns the path to the dotnet executable if the required .NET runtime version is available, otherwise raises an exception.
"""
if not self.is_required_version_available():
raise SolidLSPException(
f"Required .NET runtime version {self._required_version_str} not found "
f"(installed versions: {self._installed_versions}). "
"Please install the required .NET runtime version from https://dotnet.microsoft.com/en-us/download/dotnet "
"and ensure that `dotnet` is on the system PATH."
)
assert self._system_dotnet is not None
return self._system_dotnet
@staticmethod
def install_dotnet_with_script(version: str, base_path: str) -> str:
"""
Install .NET runtime using Microsoft's official installation script.
NOTE: This method is unreliable and therefore currently unused. It is kept for reference.
:version: the version to install as a string (e.g. "10.0")
:return: the path to the dotnet executable.
"""
dotnet_dir = Path(base_path) / f"dotnet-runtime-{version}"
# Determine binary name based on platform
is_windows = platform.system().lower() == "windows"
dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet")
if dotnet_exe.exists():
log.info(f"Using cached .NET {version} runtime from {dotnet_exe}")
return str(dotnet_exe)
# Download and run install script
log.info(f"Installing .NET {version} runtime using official Microsoft install script...")
dotnet_dir.mkdir(parents=True, exist_ok=True)
try:
if is_windows:
# PowerShell script for Windows
script_url = "https://dot.net/v1/dotnet-install.ps1"
script_path = dotnet_dir / "dotnet-install.ps1"
urllib.request.urlretrieve(script_url, script_path)
cmd = [
"pwsh",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script_path),
"-Version",
version,
"-InstallDir",
str(dotnet_dir),
"-Runtime",
"dotnet",
"-NoPath",
]
else:
# Bash script for Linux/macOS
script_url = "https://dot.net/v1/dotnet-install.sh"
script_path = dotnet_dir / "dotnet-install.sh"
urllib.request.urlretrieve(script_url, script_path)
script_path.chmod(0o755)
cmd = [
"bash",
str(script_path),
"--version",
version,
"--install-dir",
str(dotnet_dir),
"--runtime",
"dotnet",
"--no-path",
]
# Run the install script
log.info("Running .NET install script: %s", cmd)
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
log.debug(f"Install script output: {result.stdout}")
if not dotnet_exe.exists():
raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation")
log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}")
return str(dotnet_exe)
except subprocess.CalledProcessError as e:
raise SolidLSPException(f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}") from e
except Exception as e:
message = f"Failed to install .NET {version} runtime: {e}"
if is_windows and isinstance(e, FileNotFoundError):
message += "; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system."
raise SolidLSPException(message) from e
+13 -5
View File
@@ -1,6 +1,6 @@
import logging
import os
from collections.abc import Generator
from collections.abc import Callable, Iterator
from typing import TypeVar
from serena.util.file_system import find_all_non_ignored_files
@@ -11,12 +11,20 @@ T = TypeVar("T")
log = logging.getLogger(__name__)
def iter_subclasses(cls: type[T], recursive: bool = True) -> Generator[type[T], None, None]:
"""Iterate over all subclasses of a class. If recursive is True, also iterate over all subclasses of all subclasses."""
def iter_subclasses(
cls: type[T], recursive: bool = True, inclusion_predicate: Callable[[type[T]], bool] = lambda t: True
) -> Iterator[type[T]]:
"""Iterate over all subclasses of a class.
:param cls: The class whose subclasses to iterate over.
:param recursive: If True, also iterate over all subclasses of all subclasses.
:param inclusion_predicate: a predicate function to decide whether to include a subclass in the result
"""
for subclass in cls.__subclasses__():
yield subclass
if inclusion_predicate(subclass):
yield subclass
if recursive:
yield from iter_subclasses(subclass, recursive)
yield from iter_subclasses(subclass, recursive, inclusion_predicate)
def determine_programming_language_composition(repo_path: str) -> dict[Language, float]:
+1 -1
View File
@@ -23,7 +23,7 @@ class Version:
self.version_string = version_string
self.components = self._get_version_components(version_string)
def __str__(self) -> str:
def __repr__(self) -> str:
return self.version_string
@staticmethod
@@ -6,7 +6,6 @@ import logging
import os
import platform
import shutil
import subprocess
import threading
import urllib.request
from collections.abc import Iterable
@@ -15,6 +14,7 @@ from typing import Any, cast
from overrides import override
from serena.util.dotnet import DotNETUtil
from solidlsp.ls import DocumentSymbols, LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
@@ -366,21 +366,7 @@ class CSharpLanguageServer(SolidLanguageServer):
def _ensure_dotnet_runtime(self) -> str:
"""Ensure .NET runtime is available and return the dotnet executable path."""
# Check if dotnet is already available on the system
system_dotnet = shutil.which("dotnet")
if system_dotnet:
# Check if it's .NET 10 or compatible
try:
result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True)
# Accept .NET 10 or higher (10.x, 11.x, etc.)
if any(f"Microsoft.NETCore.App {v}." in result.stdout for v in range(10, 20)):
log.info("Found system .NET 10+ runtime")
return system_dotnet
except subprocess.CalledProcessError:
pass
# Install .NET 10 runtime using Microsoft's install script
return self._install_dotnet_with_script()
return DotNETUtil("10.0", allow_higher_version=True).get_dotnet_path_or_raise()
def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str:
"""Ensure language server is available and return the DLL path."""
@@ -475,89 +461,6 @@ class CSharpLanguageServer(SolidLanguageServer):
except Exception as e:
raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e
def _install_dotnet_with_script(self, version: str = "10.0") -> str:
"""
Install .NET runtime using Microsoft's official install script.
Returns the path to the dotnet executable.
"""
dotnet_dir = Path(self._ls_resources_dir) / f"dotnet-runtime-{version}"
# Determine binary name based on platform
is_windows = platform.system().lower() == "windows"
dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet")
if dotnet_exe.exists():
log.info(f"Using cached .NET {version} runtime from {dotnet_exe}")
return str(dotnet_exe)
# Download and run install script
log.info(f"Installing .NET {version} runtime using official Microsoft install script...")
dotnet_dir.mkdir(parents=True, exist_ok=True)
try:
if is_windows:
# PowerShell script for Windows
script_url = "https://dot.net/v1/dotnet-install.ps1"
script_path = dotnet_dir / "dotnet-install.ps1"
urllib.request.urlretrieve(script_url, script_path)
cmd = [
"pwsh",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script_path),
"-Version",
version,
"-InstallDir",
str(dotnet_dir),
"-Runtime",
"dotnet",
"-NoPath",
]
else:
# Bash script for Linux/macOS
script_url = "https://dot.net/v1/dotnet-install.sh"
script_path = dotnet_dir / "dotnet-install.sh"
urllib.request.urlretrieve(script_url, script_path)
script_path.chmod(0o755)
cmd = [
"bash",
str(script_path),
"--version",
version,
"--install-dir",
str(dotnet_dir),
"--runtime",
"dotnet",
"--no-path",
]
# Run the install script
log.info("Running .NET install script: %s", cmd)
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
log.debug(f"Install script output: {result.stdout}")
if not dotnet_exe.exists():
raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation")
log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}")
return str(dotnet_exe)
except subprocess.CalledProcessError as e:
raise SolidLSPException(
f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}"
) from e
except Exception as e:
message = f"Failed to install .NET {version} runtime: {e}"
if is_windows and isinstance(e, FileNotFoundError):
message += (
"; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system."
)
raise SolidLSPException(message) from e
def _get_initialize_params(self) -> InitializeParams:
"""
Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer.
@@ -11,6 +11,7 @@ from pathlib import Path
from overrides import override
from serena.util.dotnet import DotNETUtil
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
@@ -61,21 +62,7 @@ class FSharpLanguageServer(SolidLanguageServer):
"""
Setup runtime dependencies for F# Language Server and return the command to start the server.
"""
# First check if .NET SDK is installed
dotnet_exe = shutil.which("dotnet")
if not dotnet_exe:
raise RuntimeError(
".NET SDK is not installed or not in PATH. Please install .NET SDK 8.0 or later and ensure 'dotnet' is in your PATH."
)
# Verify dotnet version
import subprocess
try:
result = subprocess.run([dotnet_exe, "--version"], capture_output=True, text=True, check=True)
log.info(f"Found .NET SDK version: {result.stdout.strip()}")
except subprocess.CalledProcessError:
raise RuntimeError("Failed to get .NET SDK version. Please ensure .NET SDK is properly installed.")
dotnet_exe = DotNETUtil("8.0", allow_higher_version=True).get_dotnet_path_or_raise()
RuntimeDependencyCollection(
[
+1 -1
View File
@@ -165,7 +165,7 @@ class Gopls(SolidLanguageServer):
_CACHE_CONTEXT_ENV_KEYS = ("GOFLAGS", "GOOS", "GOARCH", "CGO_ENABLED")
@override
def _cache_context_fingerprint(self) -> str | None:
def _document_symbols_cache_fingerprint(self) -> str | None:
"""
Compute a deterministic fingerprint of the Go build context.
@@ -0,0 +1,219 @@
"""
Shader language server using shader-language-server (antaalt/shader-sense).
Supports HLSL, GLSL, and WGSL shader file formats.
"""
import logging
import os
import pathlib
import shutil
from typing import Any, cast
from overrides import override
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
from .common import RuntimeDependency, RuntimeDependencyCollection
log = logging.getLogger(__name__)
# GitHub release version to download when not installed locally
_DEFAULT_VERSION = "1.3.0"
_GITHUB_RELEASE_BASE = "https://github.com/antaalt/shader-sense/releases/download"
class HlslLanguageServer(SolidLanguageServer):
"""
Shader language server using shader-language-server.
Supports .hlsl, .hlsli, .fx, .fxh, .cginc, .compute, .shader, .glsl, .vert, .frag, .geom, .tesc, .tese, .comp, .wgsl files.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None:
super().__init__(config, repository_root_path, None, "hlsl", solidlsp_settings)
@override
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
# 1. Check PATH for system-installed binary
system_binary = shutil.which("shader-language-server")
if system_binary:
log.info(f"Using system-installed shader-language-server at {system_binary}")
return system_binary
# 2. Try to download pre-built binary from GitHub releases
version = self._custom_settings.get("version", _DEFAULT_VERSION)
tag = f"v{version}"
base_url = f"{_GITHUB_RELEASE_BASE}/{tag}"
# macOS has no pre-built binaries; build from source via cargo install
cargo_install_cmd = f"cargo install shader_language_server --version {version} --root ."
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="shader-language-server",
description="shader-language-server for Windows (x64)",
url=f"{base_url}/shader-language-server-x86_64-pc-windows-msvc.zip",
platform_id="win-x64",
archive_type="zip",
binary_name="shader-language-server.exe",
),
RuntimeDependency(
id="shader-language-server",
description="shader-language-server for Linux (x64)",
url=f"{base_url}/shader-language-server-x86_64-unknown-linux-gnu.zip",
platform_id="linux-x64",
archive_type="zip",
binary_name="shader-language-server",
),
RuntimeDependency(
id="shader-language-server",
description="shader-language-server for Windows (ARM64)",
url=f"{base_url}/shader-language-server-aarch64-pc-windows-msvc.zip",
platform_id="win-arm64",
archive_type="zip",
binary_name="shader-language-server.exe",
),
RuntimeDependency(
id="shader-language-server",
description="shader-language-server for macOS (x64) - built from source",
command=cargo_install_cmd,
platform_id="osx-x64",
binary_name="bin/shader-language-server",
),
RuntimeDependency(
id="shader-language-server",
description="shader-language-server for macOS (ARM64) - built from source",
command=cargo_install_cmd,
platform_id="osx-arm64",
binary_name="bin/shader-language-server",
),
]
)
try:
dep = deps.get_single_dep_for_current_platform()
except RuntimeError:
dep = None
if dep is None:
raise FileNotFoundError(
"shader-language-server is not installed and no auto-install is available for your platform.\n"
"Please install it using one of the following methods:\n"
" cargo: cargo install shader_language_server\n"
" GitHub: Download from https://github.com/antaalt/shader-sense/releases\n"
"On macOS, install the Rust toolchain (https://rustup.rs) and Serena will build from source automatically.\n"
"See https://github.com/antaalt/shader-sense for more details."
)
install_dir = os.path.join(self._ls_resources_dir, "shader-language-server")
executable_path = deps.binary_path(install_dir)
if not os.path.exists(executable_path):
log.info(f"shader-language-server not found. Downloading from {dep.url}")
_ = deps.install(install_dir)
if not os.path.exists(executable_path):
raise FileNotFoundError(f"shader-language-server not found at {executable_path}")
os.chmod(executable_path, 0o755)
return executable_path
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {
"dynamicRegistration": True,
"completionItem": {"snippetSupport": True},
},
"definition": {"dynamicRegistration": True},
"hover": {
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"],
},
"signatureHelp": {
"dynamicRegistration": True,
"signatureInformation": {
"parameterInformation": {"labelOffsetSupport": True},
},
},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"formatting": {"dynamicRegistration": True},
"publishDiagnostics": {"relatedInformation": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"configuration": True,
},
},
"workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}],
}
return cast(InitializeParams, initialize_params)
@override
def _start_server(self) -> None:
def do_nothing(params: Any) -> None:
return
def on_log_message(params: Any) -> None:
message = params.get("message", "") if isinstance(params, dict) else str(params)
log.info(f"shader-language-server: {message}")
def on_configuration_request(params: Any) -> list[dict]:
"""Respond to workspace/configuration requests.
shader-language-server requests config with section 'shader-validator'.
Return empty config to use defaults.
"""
items = params.get("items", []) if isinstance(params, dict) else []
return [{}] * len(items)
self.server.on_request("client/registerCapability", do_nothing)
self.server.on_request("workspace/configuration", on_configuration_request)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("window/logMessage", on_log_message)
log.info("Starting shader-language-server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request")
init_response = self.server.send.initialize(initialize_params)
capabilities = init_response.get("capabilities", {})
log.info(f"Initialize response capabilities: {list(capabilities.keys())}")
assert "textDocumentSync" in capabilities, "shader-language-server must support textDocumentSync"
if "documentSymbolProvider" not in capabilities:
log.warning("shader-language-server does not advertise documentSymbolProvider")
if "definitionProvider" not in capabilities:
log.warning("shader-language-server does not advertise definitionProvider")
self.server.notify.initialized({})
@override
def is_ignored_dirname(self, dirname: str) -> bool:
"""Ignore Unity-specific directories that contain no user-authored shaders."""
return super().is_ignored_dirname(dirname) or dirname in {"Library", "Temp", "Logs", "obj", "Packages"}
+38 -1
View File
@@ -6,11 +6,19 @@ Contains various configurations and settings specific to Markdown.
import logging
import os
import pathlib
from collections.abc import Hashable
from overrides import override
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls import (
DocumentSymbols,
LanguageServerDependencyProvider,
LanguageServerDependencyProviderSinglePath,
LSPFileBuffer,
SolidLanguageServer,
)
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
@@ -106,6 +114,35 @@ class Marksman(SolidLanguageServer):
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["node_modules", ".obsidian", ".vitepress", ".vuepress"]
def _document_symbols_cache_fingerprint(self) -> Hashable | None:
request_document_symbols_override_version = 1
return request_document_symbols_override_version
@override
def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
"""Override to remap Marksman's heading symbol kinds from String to Namespace.
Marksman LSP returns all markdown headings (h1-h6) with SymbolKind.String (15).
This is problematic because String (15) >= Variable (13), so headings are
classified as "low-level" and filtered out of symbol overviews.
Remapping to Namespace (3) fixes this and is semantically appropriate
(headings are named sections containing other content).
"""
document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer)
# NOTE: When changing this method, also update the cache fingerprint method above
def remap_heading_kinds(symbol: UnifiedSymbolInformation) -> None:
if symbol["kind"] == SymbolKind.String:
symbol["kind"] = SymbolKind.Namespace
for child in symbol.get("children", []):
remap_heading_kinds(child)
for sym in document_symbols.root_symbols:
remap_heading_kinds(sym)
return document_symbols
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
@@ -0,0 +1,432 @@
"""
Provides OCaml and Reason specific instantiation of the SolidLanguageServer class.
Contains various configurations and settings specific to OCaml and Reason.
"""
import logging
import os
import pathlib
import platform
import re
import shutil
import stat
import subprocess
import threading
from typing import Any
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_kwargs
log = logging.getLogger(__name__)
class OcamlLanguageServer(SolidLanguageServer):
"""
Provides OCaml and Reason specific instantiation of the SolidLanguageServer class.
Contains various configurations and settings specific to OCaml and Reason.
"""
_ocaml_version: tuple[int, int, int]
_lsp_version: tuple[int, int, int]
_index_built: bool
# Minimum LSP version for reliable cross-file references
MIN_LSP_VERSION_FOR_CROSS_FILE_REFS: tuple[int, int, int] = (1, 23, 0)
@staticmethod
def _ensure_opam_installed() -> None:
"""Ensure OPAM is installed and available."""
opam_path = shutil.which("opam")
if opam_path is None:
raise RuntimeError(
"OPAM is not installed or not in PATH.\n"
"Please install OPAM from: https://opam.ocaml.org/doc/Install.html\n\n"
"Installation instructions:\n"
" - macOS: brew install opam\n"
" - Ubuntu/Debian: sudo apt install opam\n"
" - Fedora: sudo dnf install opam\n"
" - Windows: https://fdopen.github.io/opam-repository-mingw/installation/\n\n"
"After installation, initialize OPAM with: opam init"
)
@staticmethod
def _detect_ocaml_version(repository_root_path: str) -> tuple[int, int, int]:
"""
Detect and return the OCaml version as a tuple (major, minor, patch).
Also checks for version compatibility with ocaml-lsp-server.
Raises RuntimeError if version cannot be determined.
"""
try:
result = subprocess.run(
["opam", "exec", "--", "ocaml", "-version"],
check=True,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout)
if version_match:
major = int(version_match.group(1))
minor = int(version_match.group(2))
patch = int(version_match.group(3))
version_tuple = (major, minor, patch)
version_str = f"{major}.{minor}.{patch}"
log.info(f"OCaml version: {version_str}")
if version_tuple == (5, 1, 0):
raise RuntimeError(
f"OCaml {version_str} is incompatible with ocaml-lsp-server.\n"
"Please use OCaml < 5.1 or >= 5.1.1.\n"
"Consider creating a new opam switch:\n"
" opam switch create <name> ocaml-base-compiler.4.14.2"
)
return version_tuple
raise RuntimeError(
f"Could not parse OCaml version from output: {result.stdout.strip()}\n"
"Please ensure OCaml is properly installed: opam exec -- ocaml -version"
)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Failed to detect OCaml version: {e.stderr}\n"
"Please ensure OCaml is installed and opam is configured:\n"
" opam switch show\n"
" opam exec -- ocaml -version"
) from e
except FileNotFoundError as e:
raise RuntimeError(
"OCaml not found. Please install OCaml via opam:\n"
" opam switch create <name> ocaml-base-compiler.4.14.2\n"
" eval $(opam env)"
) from e
@staticmethod
def _detect_lsp_version(repository_root_path: str) -> tuple[int, int, int]:
"""
Detect and return the ocaml-lsp-server version as a tuple (major, minor, patch).
Raises RuntimeError if version cannot be determined.
"""
try:
result = subprocess.run(
["opam", "list", "-i", "ocaml-lsp-server", "--columns=version", "--short"],
check=True,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
version_str = result.stdout.strip()
version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_str)
if version_match:
major = int(version_match.group(1))
minor = int(version_match.group(2))
patch = int(version_match.group(3))
version_tuple = (major, minor, patch)
log.info(f"ocaml-lsp-server version: {major}.{minor}.{patch}")
return version_tuple
raise RuntimeError(
f"Could not parse ocaml-lsp-server version from output: {version_str}\n"
"Please ensure ocaml-lsp-server is properly installed:\n"
" opam list -i ocaml-lsp-server"
)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Failed to detect ocaml-lsp-server version: {e.stderr}\n"
"Please install ocaml-lsp-server:\n"
" opam install ocaml-lsp-server"
) from e
except FileNotFoundError as e:
raise RuntimeError("opam not found. Please install opam:\n https://opam.ocaml.org/doc/Install.html") from e
@staticmethod
def _ensure_ocaml_lsp_installed(repository_root_path: str) -> str:
"""
Ensure ocaml-lsp-server is installed and return the executable path.
Raises RuntimeError with helpful message if not installed.
"""
# Check if ocaml-lsp-server is installed
try:
result = subprocess.run(
["opam", "list", "-i", "ocaml-lsp-server"],
check=False,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
if "ocaml-lsp-server" not in result.stdout or "# No matches found" in result.stdout:
raise RuntimeError(
"ocaml-lsp-server is not installed.\n\n"
"Please install it with:\n"
" opam install ocaml-lsp-server\n\n"
"Note: ocaml-lsp-server requires OCaml < 5.1 or >= 5.1.1 (OCaml 5.1.0 is not supported).\n"
"If you have OCaml 5.1.0, create a new opam switch with a compatible version:\n"
" opam switch create <name> ocaml-base-compiler.4.14.2\n"
" opam switch <name>\n"
" eval $(opam env)\n"
" opam install ocaml-lsp-server\n\n"
"For more information: https://github.com/ocaml/ocaml-lsp"
)
log.info("ocaml-lsp-server is installed")
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to check ocaml-lsp-server installation: {e.stderr}")
# Find the executable path
try:
if platform.system() == "Windows":
result = subprocess.run(
["opam", "exec", "--", "where", "ocamllsp"],
check=True,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
executable_path = result.stdout.strip().split("\n")[0]
else:
result = subprocess.run(
["opam", "exec", "--", "which", "ocamllsp"],
check=True,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
executable_path = result.stdout.strip()
if not os.path.exists(executable_path):
raise RuntimeError(f"ocaml-lsp-server executable not found at {executable_path}")
if platform.system() != "Windows":
os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IEXEC)
return executable_path
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Failed to find ocaml-lsp-server executable.\n"
f"Command failed: {e.cmd}\n"
f"Return code: {e.returncode}\n"
f"Stderr: {e.stderr}\n\n"
"This usually means ocaml-lsp-server is not installed or not in PATH.\n"
"Try:\n"
" 1. Check opam switch: opam switch show\n"
" 2. Install ocaml-lsp-server: opam install ocaml-lsp-server\n"
" 3. Ensure opam env is activated: eval $(opam env)"
)
@property
def supports_cross_file_references(self) -> bool:
"""
Check if this OCaml environment supports cross-file references.
Cross-file references require OCaml >= 5.2 with project-wide occurrences
AND ocaml-lsp-server >= 1.23.0 for reliable cross-file reference support.
Full requirements:
- OCaml 5.2+
- ocaml-lsp-server >= 1.23.0 (earlier versions have unreliable cross-file refs)
- merlin >= 5.1-502 (provides ocaml-index tool)
- dune >= 3.16.0
- Index built via `dune build @ocaml-index`
- For best results: `dune build -w` running (enables dune RPC)
Note: Even when this returns True, cross-file refs may not work in all
cases. The LSP server needs dune's RPC server (via -w flag) to be fully
aware of the index. Without watch mode, cross-file refs are best-effort.
See: https://discuss.ocaml.org/t/ann-project-wide-occurrences-in-merlin-and-lsp/14847
"""
ocaml_ok = self._ocaml_version >= (5, 2, 0)
lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS
return ocaml_ok and lsp_ok
@staticmethod
def _build_ocaml_index_static(repository_root_path: str) -> bool:
"""
Build the OCaml index for project-wide occurrences.
This enables cross-file reference finding on OCaml 5.2+.
Must be called BEFORE starting the LSP server.
Returns True if successful, False otherwise.
"""
log.info("Building OCaml index for cross-file references (dune build @ocaml-index)...")
try:
result = subprocess.run(
["opam", "exec", "--", "dune", "build", "@ocaml-index"],
cwd=repository_root_path,
capture_output=True,
text=True,
timeout=120,
check=False,
**subprocess_kwargs(),
)
if result.returncode == 0:
log.info("OCaml index built successfully")
return True
else:
log.warning(f"Failed to build OCaml index: {result.stderr}")
return False
except subprocess.TimeoutExpired:
log.warning("OCaml index build timed out after 120 seconds")
return False
except FileNotFoundError:
log.warning("opam not found, cannot build OCaml index")
return False
except Exception as e:
log.warning(f"Error building OCaml index: {e}")
return False
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates an OcamlLanguageServer instance.
This class is not meant to be instantiated directly. Use SolidLanguageServer.create() instead.
"""
# Ensure dependencies are available
self._ensure_opam_installed()
# Detect OCaml version for feature gating
self._ocaml_version = self._detect_ocaml_version(repository_root_path)
self._index_built = False
# Verify ocaml-lsp-server is installed (we don't need the path, just validation)
self._ensure_ocaml_lsp_installed(repository_root_path)
# Detect LSP version for cross-file reference support
self._lsp_version = self._detect_lsp_version(repository_root_path)
# Build OCaml index BEFORE starting server (required for cross-file refs on OCaml 5.2+)
if self._ocaml_version >= (5, 2, 0):
self._index_built = self._build_ocaml_index_static(repository_root_path)
# Use opam exec to run ocamllsp - this ensures correct opam environment
# which is required for project-wide occurrences (cross-file references) to work
ocaml_lsp_cmd = ["opam", "exec", "--", "ocamllsp", "--fallback-read-dot-merlin"]
log.info(f"Using ocaml-lsp-server via: {' '.join(ocaml_lsp_cmd)}")
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=ocaml_lsp_cmd, cwd=repository_root_path),
"ocaml",
solidlsp_settings,
)
self.server_ready = threading.Event()
self.completions_available = threading.Event()
@override
def is_ignored_dirname(self, dirname: str) -> bool:
"""Define language-specific directories to ignore for OCaml projects."""
return super().is_ignored_dirname(dirname) or dirname in ["_build", "_opam", ".opam"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the OCaml Language Server.
Supports both OCaml and Reason.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"processId": os.getpid(),
"clientInfo": {"name": "Serena", "version": "0.1.0"},
"locale": "en",
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"capabilities": {
"workspace": {
"workspaceFolders": True,
"configuration": True,
},
"textDocument": {
"synchronization": {
"dynamicRegistration": True,
"willSave": True,
"willSaveWaitUntil": True,
"didSave": True,
},
"completion": {
"dynamicRegistration": True,
"completionItem": {
"snippetSupport": True,
"documentationFormat": ["markdown", "plaintext"],
},
},
"hover": {
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"],
},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
},
"formatting": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
},
"trace": "verbose",
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params # type: ignore[return-value]
def _start_server(self) -> None:
"""
Starts the OCaml Language Server (supports both OCaml and Reason)
"""
def register_capability_handler(params: Any) -> None:
if "registrations" in params:
for registration in params.get("registrations", []):
method = registration.get("method", "")
log.info(f"OCaml LSP registered capability: {method}")
return
def lang_status_handler(params: dict[str, Any]) -> None:
if params.get("type") == "ServiceReady" and params.get("message") == "ServiceReady":
self.server_ready.set()
def do_nothing(params: Any) -> None:
return
def window_log_message(msg: dict[str, Any]) -> None:
log.info(f"LSP: window/logMessage: {msg}")
if "initialization done" in msg.get("message", "").lower():
self.server_ready.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("language/status", lang_status_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting OCaml LSP server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
# Verify expected capabilities
capabilities = init_response.get("capabilities", {})
log.info(f"OCaml LSP capabilities: {list(capabilities.keys())}")
text_doc_sync = capabilities.get("textDocumentSync")
if isinstance(text_doc_sync, dict):
assert text_doc_sync.get("change") == 2, "Expected incremental sync"
assert "completionProvider" in capabilities, "Expected completion support"
self.server.notify.initialized({})
self.completions_available.set()
self.server_ready.set()
log.info("OCaml Language Server initialized successfully")
+14 -6
View File
@@ -1987,14 +1987,22 @@ class SolidLanguageServer(ABC):
return defining_symbol
def _cache_context_fingerprint(self) -> Hashable | None:
def _document_symbols_cache_fingerprint(self) -> Hashable | None:
"""
Return a fingerprint of any language-server-specific context that affects cached results.
Returns a fingerprint of any language server-specific aspects that result in changes
to the high-level document symbol information.
Subclasses may override to provide a deterministic value that changes when cached results
would be invalidated (e.g., build flags, environment variables).
Language servers must implement this method/change the return value
* whenever they change the `request_document_symbols` implementation to modify the returned content
* are reconfigured in a way that affects the returned contents (e.g. context-specific configuration
such as build flags or environment variables); configuration options can, in such cases, be
hashed together to produce a single fingerprint value.
Whenever the value changes, the document symbols cache will be invalidated and re-populated.
The value must be hashable and safe for inclusion in cache version tuples.
E.g. use an integer, a string or a tuple of integers/strings.
Returns None if no context-specific fingerprint is needed.
"""
return None
@@ -2005,7 +2013,7 @@ class SolidLanguageServer(ABC):
Incorporates cache context fingerprint if provided by the language server.
"""
fingerprint = self._cache_context_fingerprint()
fingerprint = self._document_symbols_cache_fingerprint()
if fingerprint is not None:
return (self.DOCUMENT_SYMBOL_CACHE_VERSION, fingerprint)
return self.DOCUMENT_SYMBOL_CACHE_VERSION
@@ -2030,7 +2038,7 @@ class SolidLanguageServer(ABC):
def _raw_document_symbols_cache_version(self) -> tuple[Hashable, ...]:
base_version: tuple[Hashable, ...] = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version)
fingerprint = self._cache_context_fingerprint()
fingerprint = self._document_symbols_cache_fingerprint()
if fingerprint is not None:
return (*base_version, fingerprint)
return base_version
+34
View File
@@ -55,6 +55,7 @@ class Language(str, Enum):
LUA = "lua"
NIX = "nix"
ERLANG = "erlang"
OCAML = "ocaml"
AL = "al"
FSHARP = "fsharp"
REGO = "rego"
@@ -105,6 +106,11 @@ class Language(str, Enum):
"""TOML language server using Taplo.
Supports TOML validation, formatting, and schema support.
"""
HLSL = "hlsl"
"""Shader language server using shader-language-server (antaalt/shader-sense).
Supports .hlsl, .hlsli, .fx, .fxh, .cginc, .compute, .shader, .glsl, .vert, .frag, .geom, .tesc, .tese, .comp, .wgsl files.
Automatically downloads shader-language-server binary.
"""
SYSTEMVERILOG = "systemverilog"
"""SystemVerilog language server using verible-verilog-ls.
Supports .sv, .svh, .v, .vh files.
@@ -218,6 +224,8 @@ class Language(str, Enum):
return FilenameMatcher("*.nix")
case self.ERLANG:
return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src")
case self.OCAML:
return FilenameMatcher("*.ml", "*.mli", "*.re", "*.rei")
case self.AL:
return FilenameMatcher("*.al", "*.dal")
case self.FSHARP:
@@ -251,6 +259,24 @@ class Language(str, Enum):
return FilenameMatcher("*.groovy", "*.gvy")
case self.MATLAB:
return FilenameMatcher("*.m", "*.mlx", "*.mlapp")
case self.HLSL:
return FilenameMatcher(
"*.hlsl",
"*.hlsli",
"*.fx",
"*.fxh",
"*.cginc",
"*.compute",
"*.shader",
"*.glsl",
"*.vert",
"*.frag",
"*.geom",
"*.tesc",
"*.tese",
"*.comp",
"*.wgsl",
)
case self.SYSTEMVERILOG:
return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh")
case _:
@@ -382,6 +408,10 @@ class Language(str, Enum):
from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer
return ErlangLanguageServer
case self.OCAML:
from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer
return OcamlLanguageServer
case self.AL:
from solidlsp.language_servers.al_language_server import ALLanguageServer
@@ -434,6 +464,10 @@ class Language(str, Enum):
from solidlsp.language_servers.matlab_language_server import MatlabLanguageServer
return MatlabLanguageServer
case self.HLSL:
from solidlsp.language_servers.hlsl_language_server import HlslLanguageServer
return HlslLanguageServer
case self.SYSTEMVERILOG:
from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer
@@ -0,0 +1,29 @@
#ifndef COMMON_HLSL
#define COMMON_HLSL
struct VertexInput
{
float3 position : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};
struct VertexOutput
{
float4 clipPos : SV_POSITION;
float3 worldNormal : TEXCOORD0;
float2 uv : TEXCOORD1;
};
float3 SafeNormalize(float3 v)
{
float len = length(v);
return len > 0.0001 ? v / len : float3(0, 0, 0);
}
float Remap(float value, float fromMin, float fromMax, float toMin, float toMax)
{
return toMin + (value - fromMin) * (toMax - toMin) / (fromMax - fromMin);
}
#endif // COMMON_HLSL
@@ -0,0 +1,26 @@
#include "common.hlsl"
RWTexture2D<float4> OutputTexture : register(u0);
Texture2D<float4> InputTexture : register(t0);
cbuffer ComputeParams : register(b0)
{
uint2 TextureSize;
float BlurRadius;
float _Pad;
};
[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
if (id.x >= TextureSize.x || id.y >= TextureSize.y)
return;
float4 color = InputTexture[id.xy];
float3 remapped = float3(
Remap(color.r, 0.0, 1.0, 0.2, 0.8),
Remap(color.g, 0.0, 1.0, 0.2, 0.8),
Remap(color.b, 0.0, 1.0, 0.2, 0.8)
);
OutputTexture[id.xy] = float4(remapped, color.a);
}
@@ -0,0 +1,37 @@
#ifndef LIGHTING_HLSL
#define LIGHTING_HLSL
#include "common.hlsl"
cbuffer LightingConstants : register(b0)
{
float4x4 ViewProjection;
float3 LightDirection;
float LightIntensity;
float3 AmbientColor;
float _Padding;
};
float3 CalculateDiffuse(float3 normal, float3 lightDir, float3 albedo)
{
float ndotl = max(dot(normal, -lightDir), 0.0);
return albedo * ndotl;
}
float3 CalculateSpecular(float3 normal, float3 lightDir, float3 viewDir, float shininess)
{
float3 halfVec = SafeNormalize(-lightDir + viewDir);
float ndoth = max(dot(normal, halfVec), 0.0);
return pow(ndoth, shininess);
}
VertexOutput TransformVertex(VertexInput input)
{
VertexOutput output;
output.clipPos = mul(ViewProjection, float4(input.position, 1.0));
output.worldNormal = input.normal;
output.uv = input.uv;
return output;
}
#endif // LIGHTING_HLSL
@@ -0,0 +1,33 @@
#ifndef TERRAIN_SDF_HLSL
#define TERRAIN_SDF_HLSL
#include "../common.hlsl"
struct SDFBrickData
{
float3 center;
float halfExtent;
int resolution;
float maxDistance;
};
float3 WorldOffset;
float SampleSDF(float3 worldPos, SDFBrickData brick)
{
float3 localPos = worldPos - brick.center;
float dist = length(localPos) - brick.halfExtent;
return dist;
}
float3 CalculateGradient(float3 worldPos, SDFBrickData brick)
{
float eps = 0.01;
float3 gradient;
gradient.x = SampleSDF(worldPos + float3(eps, 0, 0), brick) - SampleSDF(worldPos - float3(eps, 0, 0), brick);
gradient.y = SampleSDF(worldPos + float3(0, eps, 0), brick) - SampleSDF(worldPos - float3(0, eps, 0), brick);
gradient.z = SampleSDF(worldPos + float3(0, 0, eps), brick) - SampleSDF(worldPos - float3(0, 0, eps), brick);
return SafeNormalize(gradient);
}
#endif // TERRAIN_SDF_HLSL
@@ -0,0 +1,4 @@
(executable
(public_name test_repo)
(name main)
(libraries test_repo))
@@ -0,0 +1,9 @@
open Test_repo
let n = 20
let () =
let res = fib n in
Printf.printf "fib(%d) = %d\n" n res;
let greeting = DemoModule.someFunction "Hello" in
Printf.printf "%s\n" greeting
@@ -0,0 +1,26 @@
(lang dune 3.18)
(name test_repo)
(generate_opam_files true)
(source
(github username/reponame))
(authors "Author Name <author@example.com>")
(maintainers "Maintainer Name <maintainer@example.com>")
(license LICENSE)
(documentation https://url/to/documentation)
(package
(name test_repo)
(synopsis "A short synopsis")
(description "A longer description")
(depends ocaml)
(tags
("add topics" "to describe" your project)))
; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html
@@ -0,0 +1,3 @@
(library
(public_name test_repo)
(name test_repo))
@@ -0,0 +1,12 @@
module DemoModule = struct
type value = string
let someFunction s =
s ^ " More String"
end
let rec fib n =
if n < 2 then 1
else fib (n-1) + fib (n-2)
let num_domains = 2
@@ -0,0 +1,7 @@
module DemoModule : sig
type value = string
val someFunction : string -> string
end
val fib : int -> int
val num_domains : int
@@ -0,0 +1,3 @@
(test
(name test_test_repo)
(libraries test_repo))
@@ -0,0 +1,18 @@
open Test_repo
let test_fib () =
assert (fib 0 = 1);
assert (fib 1 = 1);
assert (fib 2 = 2);
assert (fib 5 = 8);
Printf.printf "fib tests passed\n"
let test_demo_module () =
let result = DemoModule.someFunction "Test" in
assert (result = "Test More String");
Printf.printf "DemoModule tests passed\n"
let () =
test_fib ();
test_demo_module ();
Printf.printf "All tests passed!\n"
@@ -0,0 +1,32 @@
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
synopsis: "A short synopsis"
description: "A longer description"
maintainer: ["Maintainer Name <maintainer@example.com>"]
authors: ["Author Name <author@example.com>"]
license: "LICENSE"
tags: ["add topics" "to describe" "your" "project"]
homepage: "https://github.com/username/reponame"
doc: "https://url/to/documentation"
bug-reports: "https://github.com/username/reponame/issues"
depends: [
"dune" {>= "3.18"}
"ocaml"
"odoc" {with-doc}
]
build: [
["dune" "subst"] {dev}
[
"dune"
"build"
"-p"
name
"-j"
jobs
"@install"
"@runtest" {with-test}
"@doc" {with-doc}
]
]
dev-repo: "git+https://github.com/username/reponame.git"
x-maintenance-intent: ["(latest)"]
+114 -68
View File
@@ -106,37 +106,19 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere
class TestSerenaAgent:
@pytest.mark.parametrize(
"serena_agent,symbol_name,expected_kind,expected_file",
[
pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python),
pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go),
pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java),
pytest.param(
Language.KOTLIN,
"Model",
"Struct",
"Model.kt",
marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
),
pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust),
pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript),
pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php),
pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure),
pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell),
pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp),
],
indirect=["serena_agent"],
)
def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str):
# skip flaky tests in CI
# TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1040
flaky_languages = {Language.FSHARP, Language.RUST}
if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci:
pytest.skip("Test is flaky and thus skipped in CI environment.")
@pytest.mark.parametrize("project", [None, str(get_repo_path(Language.PYTHON)), "non_existent_path"])
def test_agent_instantiation(self, project: str | None):
"""
Tests agent instantiation for cases where
* no project is specified at startup
* a valid project path is specified at startup
* an invalid project path is specified at startup
All cases must not raise an exception.
"""
serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False)
SerenaAgent(project=project, serena_config=serena_config)
def _assert_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None:
agent = serena_agent
find_symbol_tool = agent.get_tool(FindSymbolTool)
result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True)
@@ -164,54 +146,53 @@ class TestSerenaAgent:
assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}"
@pytest.mark.parametrize(
"serena_agent,symbol_name,def_file,ref_file",
"serena_agent,symbol_name,expected_kind,expected_file",
[
pytest.param(
Language.PYTHON,
"User",
os.path.join("test_repo", "models.py"),
os.path.join("test_repo", "services.py"),
marks=pytest.mark.python,
),
pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go),
pytest.param(
Language.JAVA,
"Model",
os.path.join("src", "main", "java", "test_repo", "Model.java"),
os.path.join("src", "main", "java", "test_repo", "Main.java"),
marks=pytest.mark.java,
),
pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python),
pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go),
pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java),
pytest.param(
Language.KOTLIN,
"Model",
os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"),
os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
"Struct",
"Model.kt",
marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
),
pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust),
pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript),
pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php),
pytest.param(
Language.CLOJURE,
"multiply",
clj.CORE_PATH,
clj.UTILS_PATH,
marks=pytest.mark.clojure,
),
pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell),
pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp),
pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript),
pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php),
pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure),
pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell),
pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp),
],
indirect=["serena_agent"],
)
def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
# skip flaky tests in CI
# TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1040
flaky_languages = {Language.TYPESCRIPT}
if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci:
pytest.skip("Test is flaky and thus skipped in CI environment.")
def test_find_symbol_stable(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None:
self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file)
@pytest.mark.parametrize(
"serena_agent,symbol_name,expected_kind,expected_file",
[
pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp),
],
indirect=["serena_agent"],
)
@pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040
def test_find_symbol_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None:
self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file)
@pytest.mark.parametrize(
"serena_agent,symbol_name,expected_kind,expected_file",
[
pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust),
],
indirect=["serena_agent"],
)
@pytest.mark.xfail(reason="Rust language server is unreliable") # See issue #1040
def test_find_symbol_rust(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None:
self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file)
def _assert_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
agent = serena_agent
# Find the symbol location first
@@ -246,6 +227,71 @@ class TestSerenaAgent:
refs = json.loads(result)
assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}"
@pytest.mark.parametrize(
"serena_agent,symbol_name,def_file,ref_file",
[
pytest.param(
Language.PYTHON,
"User",
os.path.join("test_repo", "models.py"),
os.path.join("test_repo", "services.py"),
marks=pytest.mark.python,
),
pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go),
pytest.param(
Language.JAVA,
"Model",
os.path.join("src", "main", "java", "test_repo", "Model.java"),
os.path.join("src", "main", "java", "test_repo", "Main.java"),
marks=pytest.mark.java,
),
pytest.param(
Language.KOTLIN,
"Model",
os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"),
os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"),
marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []),
),
pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust),
pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php),
pytest.param(
Language.CLOJURE,
"multiply",
clj.CORE_PATH,
clj.UTILS_PATH,
marks=pytest.mark.clojure,
),
pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell),
pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp),
],
indirect=["serena_agent"],
)
def test_find_symbol_references_stable(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file)
@pytest.mark.parametrize(
"serena_agent,symbol_name,def_file,ref_file",
[
pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript),
],
indirect=["serena_agent"],
)
@pytest.mark.xfail(reason="TypeScript language server is unreliable") # See issue #1040
def test_find_symbol_references_typescript(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file)
@pytest.mark.parametrize(
"serena_agent,symbol_name,def_file,ref_file",
[
pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp),
],
indirect=["serena_agent"],
)
@pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040
def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file)
@pytest.mark.parametrize(
"serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file",
[
@@ -1,290 +0,0 @@
"""Tests for C# language server .NET runtime installation using official install scripts."""
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer
from solidlsp.settings import SolidLSPSettings
@pytest.mark.csharp
class TestDotNetInstallScript:
"""Test .NET runtime installation using Microsoft's official install scripts."""
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server")
@patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id")
@patch("solidlsp.language_servers.csharp_language_server.platform.system")
@patch("solidlsp.language_servers.csharp_language_server.subprocess.run")
@patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve")
@patch("solidlsp.language_servers.csharp_language_server.shutil.which")
def test_install_dotnet_uses_bash_script_on_linux(
self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls
):
"""Test that Linux uses bash install script."""
from solidlsp.ls_utils import PlatformId
mock_platform.return_value = "Linux"
mock_platform_id.return_value = PlatformId.LINUX_x64
mock_which.return_value = None # No system dotnet
mock_ensure_ls.return_value = "/fake/server.dll"
with tempfile.TemporaryDirectory() as temp_dir:
def mock_retrieve(url, path):
# Create the file that urlretrieve would create
Path(path).touch()
# Also create the dotnet executable after script download
if "dotnet-install.sh" in str(path):
dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0"
dotnet_dir.mkdir(parents=True, exist_ok=True)
dotnet_exe = dotnet_dir / "dotnet"
dotnet_exe.touch()
dotnet_exe.chmod(0o755)
mock_urlretrieve.side_effect = mock_retrieve
mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="")
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
_ = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Verify bash script was downloaded
assert mock_urlretrieve.called
script_url = mock_urlretrieve.call_args[0][0]
assert script_url == "https://dot.net/v1/dotnet-install.sh"
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server")
@patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id")
@patch("solidlsp.language_servers.csharp_language_server.platform.system")
@patch("solidlsp.language_servers.csharp_language_server.subprocess.run")
@patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve")
@patch("solidlsp.language_servers.csharp_language_server.shutil.which")
def test_install_dotnet_uses_powershell_script_on_windows(
self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls
):
"""Test that Windows uses PowerShell install script."""
from solidlsp.ls_utils import PlatformId
mock_platform.return_value = "Windows"
mock_platform_id.return_value = PlatformId.WIN_x64
mock_which.return_value = None # No system dotnet
mock_ensure_ls.return_value = "/fake/server.dll"
with tempfile.TemporaryDirectory() as temp_dir:
def mock_retrieve(url, path):
# Create the file that urlretrieve would create
Path(path).touch()
# Also create the dotnet executable after script download
if "dotnet-install.ps1" in str(path):
dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0"
dotnet_dir.mkdir(parents=True, exist_ok=True)
dotnet_exe = dotnet_dir / "dotnet.exe"
dotnet_exe.touch()
mock_urlretrieve.side_effect = mock_retrieve
mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="")
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
_ = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Verify PowerShell script was downloaded
assert mock_urlretrieve.called
script_url = mock_urlretrieve.call_args[0][0]
assert script_url == "https://dot.net/v1/dotnet-install.ps1"
def test_uses_system_dotnet_10_if_available(self):
"""Test that system .NET 10 is used if available instead of downloading."""
from solidlsp.ls_utils import PlatformId
with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which:
with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess:
with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id:
with patch(
"solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server"
) as mock_ensure_ls:
mock_which.return_value = "/usr/bin/dotnet"
mock_ensure_ls.return_value = "/fake/server.dll"
mock_platform_id.return_value = PlatformId.LINUX_x64
# Mock dotnet --list-runtimes output showing .NET 10
mock_result = Mock()
mock_result.stdout = "Microsoft.NETCore.App 10.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]"
mock_result.returncode = 0
mock_subprocess.return_value = mock_result
with tempfile.TemporaryDirectory() as temp_dir:
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Should have used system dotnet
assert dependency_provider._dotnet_path == "/usr/bin/dotnet"
def test_runtime_dependencies_no_longer_include_dotnet_downloads(self):
"""Test that _RUNTIME_DEPENDENCIES no longer includes manual .NET download entries."""
from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES
# Check that DotNetRuntime entries don't exist (we use install script instead)
dotnet_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "DotNetRuntime"]
# After our changes, there should be no DotNetRuntime entries
# (we'll use the install script instead)
assert len(dotnet_deps) == 0, "DotNetRuntime dependencies should be removed in favor of install script"
@patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server")
@patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id")
@patch("solidlsp.language_servers.csharp_language_server.platform.system")
@patch("solidlsp.language_servers.csharp_language_server.subprocess.run")
@patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve")
@patch("solidlsp.language_servers.csharp_language_server.shutil.which")
def test_cached_dotnet_is_reused(self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls):
"""Test that cached .NET installation is reused without re-downloading."""
from solidlsp.ls_utils import PlatformId
mock_platform.return_value = "Linux"
mock_platform_id.return_value = PlatformId.LINUX_x64
mock_which.return_value = None
mock_ensure_ls.return_value = "/fake/server.dll"
with tempfile.TemporaryDirectory() as temp_dir:
# Pre-create the cached dotnet executable
dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0"
dotnet_dir.mkdir(parents=True, exist_ok=True)
dotnet_exe = dotnet_dir / "dotnet"
dotnet_exe.touch()
dotnet_exe.chmod(0o755)
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Should use cached dotnet without downloading install script
assert dependency_provider._dotnet_path == str(dotnet_exe)
assert not mock_urlretrieve.called, "Should not download install script when cached dotnet exists"
def test_rejects_dotnet_9_and_installs_dotnet_10(self):
"""Test that .NET 9 is rejected and .NET 10 install is triggered."""
from solidlsp.ls_utils import PlatformId
with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which:
with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess:
with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id:
with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") as mock_urlretrieve:
with patch("solidlsp.language_servers.csharp_language_server.platform.system") as mock_platform:
with patch(
"solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server"
) as mock_ensure_ls:
mock_which.return_value = "/usr/bin/dotnet"
mock_ensure_ls.return_value = "/fake/server.dll"
mock_platform_id.return_value = PlatformId.LINUX_x64
mock_platform.return_value = "Linux"
# Mock dotnet --list-runtimes output showing only .NET 9
mock_result = Mock()
mock_result.stdout = "Microsoft.NETCore.App 9.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]"
mock_result.returncode = 0
call_count = 0
def subprocess_side_effect(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
# First call: dotnet --list-runtimes returns .NET 9
return mock_result
else:
# Second call: install script execution
return Mock(returncode=0, stdout="", stderr="")
mock_subprocess.side_effect = subprocess_side_effect
with tempfile.TemporaryDirectory() as temp_dir:
def mock_retrieve(url, path):
Path(path).touch()
if "dotnet-install.sh" in str(path):
dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0"
dotnet_dir.mkdir(parents=True, exist_ok=True)
dotnet_exe = dotnet_dir / "dotnet"
dotnet_exe.touch()
dotnet_exe.chmod(0o755)
mock_urlretrieve.side_effect = mock_retrieve
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Should have installed .NET 10 (not used system .NET 9)
assert dependency_provider._dotnet_path == str(Path(temp_dir) / "dotnet-runtime-10.0" / "dotnet")
assert mock_urlretrieve.called, "Should download install script when .NET 9 found"
def test_accepts_dotnet_11_and_higher(self):
"""Test that .NET 11+ versions are accepted for forward compatibility."""
from solidlsp.ls_utils import PlatformId
for version in ["11.0.1", "12.0.0", "15.3.2"]:
with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which:
with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess:
with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id:
with patch(
"solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server"
) as mock_ensure_ls:
mock_which.return_value = "/usr/bin/dotnet"
mock_ensure_ls.return_value = "/fake/server.dll"
mock_platform_id.return_value = PlatformId.LINUX_x64
# Mock dotnet --list-runtimes output showing .NET 11+
mock_result = Mock()
mock_result.stdout = f"Microsoft.NETCore.App {version} [/usr/share/dotnet/shared/Microsoft.NETCore.App]"
mock_result.returncode = 0
mock_subprocess.return_value = mock_result
with tempfile.TemporaryDirectory() as temp_dir:
mock_settings = SolidLSPSettings()
custom_settings = SolidLSPSettings.CustomLSSettings({})
dependency_provider = CSharpLanguageServer.DependencyProvider(
custom_settings=custom_settings,
ls_resources_dir=temp_dir,
solidlsp_settings=mock_settings,
repository_root_path="/fake/repo",
)
# Should use system dotnet for .NET 11+
assert dependency_provider._dotnet_path == "/usr/bin/dotnet", f"Should accept .NET {version}"
+1 -42
View File
@@ -1,14 +1,10 @@
import os
import tempfile
import threading
from pathlib import Path
from typing import Any
from unittest.mock import Mock, patch
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
from test.conftest import is_ci
@@ -53,6 +49,7 @@ class TestFSharpLanguageServer:
for expected in expected_symbols:
assert expected in symbol_names, f"{expected} function not found in Calculator.fs symbols"
@pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test finding references using symbol selection range."""
@@ -183,41 +180,3 @@ class TestFSharpLanguageServer:
# This is a successful test - FsAutoComplete is working with F# files
assert True, "F# language server can handle files successfully"
@pytest.mark.fsharp
class TestFSharpLanguageServerSetup:
"""Test F# language server setup and configuration."""
def test_runtime_dependency_setup_without_dotnet(self) -> None:
"""Test that setup fails gracefully when .NET is not installed."""
with patch("shutil.which", return_value=None):
with pytest.raises(RuntimeError, match=r"\.NET SDK is not installed"):
FSharpLanguageServer._setup_runtime_dependencies(Mock(), Mock())
def test_runtime_dependency_setup_with_dotnet(self) -> None:
"""Test that setup works when .NET is available."""
mock_config = Mock()
mock_settings = Mock()
# Mock the ls_resources_dir to return a temp directory
with tempfile.TemporaryDirectory() as temp_dir:
with patch("shutil.which", return_value="/usr/bin/dotnet"):
with patch.object(FSharpLanguageServer, "ls_resources_dir", return_value=temp_dir):
with patch("subprocess.run") as mock_run:
# Mock successful dotnet version check
mock_run.return_value.stdout = "8.0.100"
mock_run.return_value.returncode = 0
# Create a fake fsautocomplete executable
fsharp_dir = os.path.join(temp_dir, "fsharp-lsp")
os.makedirs(fsharp_dir, exist_ok=True)
# Use .exe extension on Windows, matching production code
exe_name = "fsautocomplete.exe" if os.name == "nt" else "fsautocomplete"
fsautocomplete_path = os.path.join(fsharp_dir, exe_name)
Path(fsautocomplete_path).touch()
result = FSharpLanguageServer._setup_runtime_dependencies(mock_config, mock_settings)
assert fsautocomplete_path in result
assert "--adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" in result
+1
View File
@@ -0,0 +1 @@
+160
View File
@@ -0,0 +1,160 @@
"""
Basic tests for HLSL language server integration (shader-language-server).
This module tests Language.HLSL using shader-language-server from antaalt/shader-sense.
Tests are skipped if the language server is not available.
"""
from typing import Any
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_utils import SymbolUtils
def _find_symbol_by_name(language_server: SolidLanguageServer, file_path: str, name: str) -> dict[str, Any] | None:
"""Find a top-level symbol by name in a file's document symbols."""
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
return next((s for s in symbols[0] if s.get("name") == name), None)
# ── Symbol Discovery ─────────────────────────────────────────────
@pytest.mark.hlsl
class TestHlslSymbols:
"""Tests for document symbol extraction."""
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_find_struct(self, language_server: SolidLanguageServer) -> None:
"""VertexInput struct should appear in common.hlsl symbols."""
symbol = _find_symbol_by_name(language_server, "common.hlsl", "VertexInput")
assert symbol is not None, "Expected 'VertexInput' struct in document symbols"
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_find_function(self, language_server: SolidLanguageServer) -> None:
"""SafeNormalize function should appear in common.hlsl."""
symbol = _find_symbol_by_name(language_server, "common.hlsl", "SafeNormalize")
assert symbol is not None, "Expected 'SafeNormalize' function in document symbols"
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_find_cbuffer_members(self, language_server: SolidLanguageServer) -> None:
"""Cbuffer members should appear as variables in compute_test.hlsl.
Note: shader-language-server reports cbuffer members as individual
variables (kind 13), not the cbuffer name itself as a symbol.
"""
symbol = _find_symbol_by_name(language_server, "compute_test.hlsl", "TextureSize")
assert symbol is not None, "Expected 'TextureSize' cbuffer member in document symbols"
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_find_compute_kernel(self, language_server: SolidLanguageServer) -> None:
"""CSMain kernel should appear in compute_test.hlsl."""
symbol = _find_symbol_by_name(language_server, "compute_test.hlsl", "CSMain")
assert symbol is not None, "Expected 'CSMain' compute kernel in document symbols"
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_full_symbol_tree(self, language_server: SolidLanguageServer) -> None:
"""Full symbol tree should contain symbols from multiple files."""
symbols = language_server.request_full_symbol_tree()
assert SymbolUtils.symbol_tree_contains_name(symbols, "VertexInput"), "VertexInput not in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculateDiffuse"), "CalculateDiffuse not in symbol tree"
# ── Go-to-Definition ─────────────────────────────────────────────
@pytest.mark.hlsl
class TestHlslDefinition:
"""Tests for go-to-definition capability."""
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_goto_definition_cross_file(self, language_server: SolidLanguageServer) -> None:
"""Navigating to SafeNormalize call in lighting.hlsl should resolve to common.hlsl.
lighting.hlsl line 22 (0-indexed): " float3 halfVec = SafeNormalize(-lightDir + viewDir);"
SafeNormalize starts at column 21.
"""
definitions = language_server.request_definition("lighting.hlsl", 22, 21)
assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}"
def_paths = [d.get("relativePath", d.get("uri", "")) for d in definitions]
assert any("common.hlsl" in p for p in def_paths), f"Expected definition in common.hlsl, got: {def_paths}"
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_goto_definition_cross_file_remap(self, language_server: SolidLanguageServer) -> None:
"""Navigating to Remap call in compute_test.hlsl should resolve to common.hlsl.
compute_test.hlsl line 20 (0-indexed): " Remap(color.r, 0.0, 1.0, 0.2, 0.8),"
Remap starts at column 8.
"""
definitions = language_server.request_definition("compute_test.hlsl", 20, 8)
assert len(definitions) >= 1, f"Expected at least 1 definition, got {len(definitions)}"
def_paths = [d.get("relativePath", d.get("uri", "")) for d in definitions]
assert any("common.hlsl" in p for p in def_paths), f"Expected definition in common.hlsl, got: {def_paths}"
# ── References ────────────────────────────────────────────────────
@pytest.mark.hlsl
class TestHlslReferences:
"""Tests for find-references capability.
shader-language-server does not advertise referencesProvider, so
request_references is expected to return an empty list.
"""
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_references_not_supported(self, language_server: SolidLanguageServer) -> None:
"""References request should raise because shader-language-server does not support it.
common.hlsl line 17 (0-indexed): "float3 SafeNormalize(float3 v)"
SafeNormalize starts at column 7.
"""
with pytest.raises(SolidLSPException, match="Method not found"):
language_server.request_references("common.hlsl", 17, 7)
# ── Hover ─────────────────────────────────────────────────────────
def _extract_hover_text(hover_info: dict[str, Any]) -> str:
"""Extract the text content from an LSP hover response."""
contents = hover_info["contents"]
if isinstance(contents, dict):
return contents.get("value", "")
elif isinstance(contents, str):
return contents
return str(contents)
@pytest.mark.hlsl
class TestHlslHover:
"""Tests for hover information."""
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_hover_on_function(self, language_server: SolidLanguageServer) -> None:
"""Hovering over SafeNormalize definition should return info.
common.hlsl line 17 (0-indexed): "float3 SafeNormalize(float3 v)"
SafeNormalize starts at column 7.
"""
hover_info = language_server.request_hover("common.hlsl", 17, 7)
assert hover_info is not None, "Hover should return information for SafeNormalize"
assert "contents" in hover_info, "Hover should have contents"
hover_text = _extract_hover_text(hover_info)
assert len(hover_text) > 0, "Hover text should not be empty"
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_hover_on_struct(self, language_server: SolidLanguageServer) -> None:
"""Hovering over VertexInput should return struct info.
common.hlsl line 3 (0-indexed): "struct VertexInput"
VertexInput starts at column 7.
"""
hover_info = language_server.request_hover("common.hlsl", 3, 7)
assert hover_info is not None, "Hover should return information for VertexInput"
assert "contents" in hover_info, "Hover should have contents"
@@ -0,0 +1,63 @@
"""
Regression tests for HLSL full symbol tree indexing.
These tests verify that request_full_symbol_tree() correctly indexes all files,
including .hlsl includes in subdirectories. This catches bugs where files are
silently dropped during workspace-wide indexing.
"""
from typing import Any
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from solidlsp.ls_utils import SymbolUtils
def _collect_file_names(symbols: list[dict[str, Any]]) -> set[str]:
"""Recursively collect the names of all File-kind symbols in the tree."""
names: set[str] = set()
for sym in symbols:
if sym.get("kind") == SymbolKind.File:
names.add(sym["name"])
if "children" in sym:
names.update(_collect_file_names(sym["children"]))
return names
EXPECTED_FILES = {"common", "lighting", "compute_test", "terrain_sdf"}
TERRAIN_SDF_UNIQUE_SYMBOLS = {"SampleSDF", "CalculateGradient", "SDFBrickData"}
@pytest.mark.hlsl
class TestHlslFullIndex:
"""Tests for full symbol tree indexing completeness."""
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_all_files_indexed_in_symbol_tree(self, language_server: SolidLanguageServer) -> None:
"""Every .hlsl file in the test repo must appear as a File symbol in the tree."""
symbols = language_server.request_full_symbol_tree()
file_names = _collect_file_names(symbols)
missing = EXPECTED_FILES - file_names
assert not missing, f"Files missing from full symbol tree: {missing}. Found: {file_names}"
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_subdirectory_file_symbols_present(self, language_server: SolidLanguageServer) -> None:
"""Symbols unique to terrain/terrain_sdf.hlsl must appear in the full tree."""
symbols = language_server.request_full_symbol_tree()
for name in TERRAIN_SDF_UNIQUE_SYMBOLS:
assert SymbolUtils.symbol_tree_contains_name(
symbols, name
), f"Expected '{name}' from terrain/terrain_sdf.hlsl in full symbol tree"
@pytest.mark.parametrize("language_server", [Language.HLSL], indirect=True)
def test_include_file_document_symbols_directly(self, language_server: SolidLanguageServer) -> None:
"""request_document_symbols on terrain/terrain_sdf.hlsl should return its symbols."""
doc_symbols = language_server.request_document_symbols("terrain/terrain_sdf.hlsl")
all_symbols = doc_symbols.get_all_symbols_and_roots()
symbol_names = {s.get("name") for s in all_symbols[0]}
for name in TERRAIN_SDF_UNIQUE_SYMBOLS:
assert name in symbol_names, f"Expected '{name}' in document symbols for terrain/terrain_sdf.hlsl, got: {symbol_names}"
+35 -5
View File
@@ -7,8 +7,10 @@ like request_document_symbols using the markdown test repository.
import pytest
from serena.symbol import LanguageServerSymbol
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
@pytest.mark.markdown
@@ -24,17 +26,19 @@ class TestMarkdownLanguageServerBasics:
@pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True)
def test_markdown_request_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test request_document_symbols for markdown files."""
# Test getting symbols from README.md
all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots()
# Extract heading symbols (LSP Symbol Kind 15 is String, but marksman uses kind 15 for headings)
# Note: Different markdown LSPs may use different symbol kinds for headings
# Marksman typically uses kind 15 (String) for markdown headings
heading_names = [symbol["name"] for symbol in all_symbols]
# Should detect headings from README.md
assert "Test Repository" in heading_names or len(all_symbols) > 0, "Should find at least one heading"
# Verify that markdown headings are remapped from String to Namespace
for symbol in all_symbols:
assert (
symbol["kind"] == SymbolKind.Namespace
), f"Heading '{symbol['name']}' should have kind Namespace, got {SymbolKind(symbol['kind']).name}"
@pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True)
def test_markdown_request_symbols_from_guide(self, language_server: SolidLanguageServer) -> None:
"""Test symbol detection in guide.md file."""
@@ -54,7 +58,6 @@ class TestMarkdownLanguageServerBasics:
@pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True)
def test_markdown_request_document_symbols_with_body(self, language_server: SolidLanguageServer) -> None:
"""Test request_document_symbols with body extraction."""
# Test with include_body=True
all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots()
# Should have found some symbols
@@ -63,3 +66,30 @@ class TestMarkdownLanguageServerBasics:
# Note: Not all markdown LSPs provide body information for symbols
# This test is more lenient and just verifies the API works
assert all_symbols is not None, "Should return symbols even if body extraction is limited"
@pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True)
def test_markdown_headings_not_low_level(self, language_server: SolidLanguageServer) -> None:
"""Test that markdown headings are not classified as low-level symbols.
Verifies the fix for the issue where Marksman's SymbolKind.String (15)
caused all headings to be filtered out of get_symbols_overview.
"""
all_symbols, _root_symbols = language_server.request_document_symbols("README.md").get_all_symbols_and_roots()
assert len(all_symbols) > 0, "Should find headings in README.md"
for symbol in all_symbols:
ls_symbol = LanguageServerSymbol(symbol)
assert (
not ls_symbol.is_low_level()
), f"Heading '{symbol['name']}' should not be low-level (kind={SymbolKind(symbol['kind']).name})"
@pytest.mark.parametrize("language_server", [Language.MARKDOWN], indirect=True)
def test_markdown_nested_headings_remapped(self, language_server: SolidLanguageServer) -> None:
"""Test that nested headings (h1-h5) are all remapped from String to Namespace."""
all_symbols, _root_symbols = language_server.request_document_symbols("api.md").get_all_symbols_and_roots()
# api.md has deeply nested headings (h1 through h5)
assert len(all_symbols) > 5, "api.md should have many headings"
for symbol in all_symbols:
assert symbol["kind"] == SymbolKind.Namespace, f"Nested heading '{symbol['name']}' should be remapped to Namespace"
@@ -0,0 +1,75 @@
"""
Test cross-file references for OCaml.
Cross-file references require OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0.
On environments without these (e.g. Windows CI with OCaml 4.14), only
same-file references are asserted.
"""
import logging
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer
from solidlsp.ls_config import Language
log = logging.getLogger(__name__)
@pytest.mark.ocaml
class TestCrossFileReferences:
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_fib_has_cross_file_references(self, language_server: SolidLanguageServer) -> None:
"""Test that fib function references are found across multiple files.
The `fib` function is defined in lib/test_repo.ml and used in:
- lib/test_repo.ml (definition + 2 recursive calls)
- bin/main.ml (1 call)
- test/test_test_repo.ml (5 references)
Total: 9 references across 3 files.
"""
file_path = os.path.join("lib", "test_repo.ml")
fib_line = 7
fib_char = 8
refs = language_server.request_references(file_path, fib_line, fib_char)
lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
bin_refs = [ref for ref in refs if "bin/main.ml" in ref.get("uri", "")]
test_refs = [ref for ref in refs if "test/test_test_repo.ml" in ref.get("uri", "")]
log.info("Cross-file references result:")
log.info(f"Total references found: {len(refs)}")
log.info(f" lib/test_repo.ml: {len(lib_refs)}")
log.info(f" bin/main.ml: {len(bin_refs)}")
log.info(f" test/test_test_repo.ml: {len(test_refs)}")
for ref in refs:
uri = ref.get("uri", "")
filename = uri.split("/")[-1]
line = ref.get("range", {}).get("start", {}).get("line", -1)
log.info(f" {filename}:{line}")
# Same-file references always work
assert len(lib_refs) >= 3, f"Expected at least 3 references in lib/test_repo.ml (definition + 2 recursive), but got {len(lib_refs)}"
# Cross-file references require OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0
if isinstance(language_server, OcamlLanguageServer) and language_server.supports_cross_file_references:
assert len(refs) >= 9, (
f"Expected at least 9 total references (3 in lib + 1 in bin + 5 in test), "
f"but got {len(refs)}. Cross-file references are NOT working!"
)
assert len(bin_refs) >= 1, (
f"Expected at least 1 reference in bin/main.ml, but got {len(bin_refs)}. "
"Cross-file references are NOT working - bin/main.ml not found!"
)
assert len(test_refs) >= 1, (
f"Expected at least 1 reference in test/test_test_repo.ml, but got {len(test_refs)}. "
"Cross-file references are NOT working - test file not found!"
)
+152
View File
@@ -0,0 +1,152 @@
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
@pytest.mark.ocaml
class TestOCamlLanguageServer:
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_find_symbol(self, language_server: SolidLanguageServer) -> None:
symbols = language_server.request_full_symbol_tree()
assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoModule"), "DemoModule not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "fib"), "fib not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "someFunction"), "someFunction function not found in symbol tree"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None:
file_path = os.path.join("lib", "test_repo.ml")
# Use the correct character position for 'fib' function name
# Line 8: "let rec fib n =" - 'fib' starts at character 8 (0-indexed)
fib_line = 7 # 0-indexed line number
fib_char = 8 # 0-indexed character position
refs = language_server.request_references(file_path, fib_line, fib_char)
# Should find at least 3 references: definition + 2 recursive calls in same file
assert len(refs) >= 3, f"Expected at least 3 references to fib (definition + 2 recursive), found {len(refs)}"
# All references should be in lib/test_repo.ml (same file as definition)
# Use forward slashes for URI matching (URIs always use /)
lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
assert len(lib_refs) >= 3, f"Expected at least 3 references in lib/test_repo.ml, found {len(lib_refs)}"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_mixed_ocaml_modules(self, language_server: SolidLanguageServer) -> None:
"""Test that the language server can find symbols from OCaml modules"""
# Test that full symbol tree includes symbols from various file types
all_symbols = language_server.request_full_symbol_tree()
# Should find symbols from main OCaml files
assert SymbolUtils.symbol_tree_contains_name(all_symbols, "fib"), "Should find fib from .ml file"
assert SymbolUtils.symbol_tree_contains_name(all_symbols, "DemoModule"), "Should find DemoModule from .ml file"
assert SymbolUtils.symbol_tree_contains_name(all_symbols, "someFunction"), "Should find someFunction from DemoModule"
assert SymbolUtils.symbol_tree_contains_name(all_symbols, "num_domains"), "Should find num_domains constant"
def test_reason_file_patterns(self) -> None:
"""Test that OCaml language configuration recognizes Reason file extensions"""
from solidlsp.ls_config import Language
ocaml_lang = Language.OCAML
file_matcher = ocaml_lang.get_source_fn_matcher()
# Test OCaml extensions
assert file_matcher.is_relevant_filename("test.ml"), "Should match .ml files"
assert file_matcher.is_relevant_filename("test.mli"), "Should match .mli files"
# Test Reason extensions
assert file_matcher.is_relevant_filename("test.re"), "Should match .re files"
assert file_matcher.is_relevant_filename("test.rei"), "Should match .rei files"
# Test non-matching extensions
assert not file_matcher.is_relevant_filename("test.py"), "Should not match .py files"
assert not file_matcher.is_relevant_filename("test.js"), "Should not match .js files"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_module_hierarchy_navigation(self, language_server: SolidLanguageServer) -> None:
"""Test navigation within module hierarchy including DemoModule."""
file_path = os.path.join("lib", "test_repo.ml")
# Use correct position for 'DemoModule' (line 1, char 7)
# Line 1: "module DemoModule = struct" - 'DemoModule' starts around char 7
module_line = 0 # 0-indexed
module_char = 7 # 0-indexed
refs = language_server.request_references(file_path, module_line, module_char)
# Should find at least 1 reference (the definition)
assert len(refs) >= 1, f"Expected at least 1 reference to DemoModule, found {len(refs)}"
# Check that references are found - use forward slashes for URI matching
lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
assert len(lib_refs) >= 1, f"Expected at least 1 reference in lib/test_repo.ml, found {len(lib_refs)}"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_let_binding_references(self, language_server: SolidLanguageServer) -> None:
"""Test finding references to let-bound values across files."""
file_path = os.path.join("lib", "test_repo.ml")
# Use correct position for 'num_domains' (line 12, char 4)
# Line 12: "let num_domains = 2" - 'num_domains' starts around char 4
num_domains_line = 11 # 0-indexed
num_domains_char = 4 # 0-indexed
refs = language_server.request_references(file_path, num_domains_line, num_domains_char)
# Should find at least 1 reference (the definition)
assert len(refs) >= 1, f"Expected at least 1 reference to num_domains, found {len(refs)}"
# Check that reference is found in the definition file - use forward slashes
ml_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
assert len(ml_refs) >= 1, f"Expected at least 1 reference in lib/test_repo.ml, found {len(ml_refs)}"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_recursive_function_analysis(self, language_server: SolidLanguageServer) -> None:
"""Test that recursive function calls are properly identified within the definition file."""
file_path = os.path.join("lib", "test_repo.ml")
# Use correct position for 'fib' function name (line 8, char 8)
fib_line = 7 # 0-indexed
fib_char = 8 # 0-indexed
refs = language_server.request_references(file_path, fib_line, fib_char)
# Filter to references within the definition file only - use forward slashes
same_file_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
# Should find at least 3 references in test_repo.ml: definition + 2 recursive calls
# On OCaml 5.2+ with cross-file refs, there may be more total refs but same-file count stays the same
assert (
len(same_file_refs) >= 3
), f"Expected at least 3 references in test_repo.ml (definition + 2 recursive), found {len(same_file_refs)}"
# Verify references are on different lines (definition + recursive calls)
ref_lines = [ref.get("range", {}).get("start", {}).get("line", -1) for ref in same_file_refs]
unique_lines = len(set(ref_lines))
assert unique_lines >= 2, f"Recursive calls should appear on multiple lines, found {unique_lines} unique lines"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_open_statement_resolution(self, language_server: SolidLanguageServer) -> None:
"""Test that open statements allow unqualified access to module contents."""
# In bin/main.ml, fib is called without Test_repo prefix due to 'open Test_repo'
all_symbols = language_server.request_full_symbol_tree()
# Should be able to find fib through symbol tree
fib_accessible = SymbolUtils.symbol_tree_contains_name(all_symbols, "fib")
assert fib_accessible, "fib should be accessible through open statement"
# DemoModule should also be accessible
demo_module_accessible = SymbolUtils.symbol_tree_contains_name(all_symbols, "DemoModule")
assert demo_module_accessible, "DemoModule should be accessible"
# Verify we have access to both qualified and unqualified symbols
assert len(all_symbols) > 0, "Should find symbols from OCaml files"
# Test that the language server recognizes the open statement context
file_path = os.path.join("bin", "main.ml")
symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
assert len(symbols) > 0, "Should find symbols in main.ml that use opened modules"
+2 -5
View File
@@ -8,12 +8,10 @@ from test.conftest import is_ci, is_windows, language_tests_enabled
_php_servers: list[Language] = [Language.PHP]
if language_tests_enabled(Language.PHP_PHPACTOR):
_php_servers.append(Language.PHP_PHPACTOR)
if not (is_windows and is_ci): # TODO: Phpactor tests are flaky in Windows CI and can even cause hangs #1040
_php_servers.append(Language.PHP_PHPACTOR)
@pytest.mark.xfail(
is_ci and is_windows, reason="Tests are flaky"
) # TODO: Re-enable once we have a solution for running Phpactor tests on Windows CI #1040
@pytest.mark.php
class TestPhpLanguageServers:
@pytest.mark.parametrize("language_server", _php_servers, indirect=True)
@@ -26,7 +24,6 @@ class TestPhpLanguageServers:
@pytest.mark.parametrize("language_server", _php_servers, indirect=True)
@pytest.mark.parametrize("repo_path", [Language.PHP], indirect=True)
def test_find_definition_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
# In index.php:
# Line 9 (1-indexed): $greeting = greet($userName);
# Line 11 (1-indexed): echo $greeting;
-4
View File
@@ -1,7 +1,6 @@
"""Tests for Rego language server (Regal) functionality."""
import os
import sys
import pytest
@@ -11,9 +10,6 @@ from solidlsp.ls_utils import SymbolUtils
@pytest.mark.rego
@pytest.mark.skipif(
sys.platform == "win32", reason="Regal LSP has Windows path handling bug - see https://github.com/StyraInc/regal/issues/1683"
)
class TestRegoLanguageServer:
"""Test Regal language server functionality for Rego."""
Generated
+675 -674
View File
File diff suppressed because it is too large. Load diff