mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 12:15:10 +00:00
Merge remote-tracking branch 'origin/main' into query-project-tool
Conflicts: src/serena/agent.py src/serena/project.py
This commit is contained in:
commit
8f3a31768e
66 files changed
+3090
-1351
No files matched your search
@@ -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
@@ -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/
|
||||
@@ -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))
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">×</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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)"]
|
||||
@@ -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,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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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}"
|
||||
@@ -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!"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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;
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user