Merge branch 'main' into mypy_on_solidlsp

Conflicts:
	src/solidlsp/language_servers/elixir_tools/elixir_tools.py
	src/solidlsp/language_servers/gopls.py
	src/solidlsp/language_servers/terraform_ls.py
	src/solidlsp/ls.py
	src/solidlsp/ls_handler.py
This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2025-11-19 22:10:16 +01:00
commit 11cb8f2811
155 files changed
+11240 -2829

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
name: Docs Build
on:
push:
branches: [ main ]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Install uv
run: pip install uv
- name: Cache dependencies
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('pyproject.toml') }}
- name: Install dependencies
run: |
uv venv
uv pip install -e ".[dev]"
- name: Build docs
run: uv run poe doc-build
continue-on-error: false
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/_build
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-47
View File
@@ -1,47 +0,0 @@
name: Linting, Types and Docs Check
on:
workflow_dispatch: # Manual trigger only - workflow disabled from auto-running
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
id-token: write
pages: write
actions: write
contents: read
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.11
# use uv package manager
- name: Install uv
run: pip install uv
- uses: actions/cache@v3
name: Cache dependencies
with:
path: ~/.cache/uv
key: uv-${{ hashFiles('pyproject.toml') }}
- name: Install dependencies
run: |
uv venv
uv pip install -e ".[dev]"
- name: Lint
run: uv run poe lint
- name: Types
run: uv run poe type-check
- name: Docs
run: uv run poe doc-build
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
with:
path: "docs/_build"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2
+34
View File
@@ -154,6 +154,40 @@ jobs:
- name: Install Julia LanguageServer
shell: bash
run: julia -e 'using Pkg; Pkg.add("LanguageServer")'
- name: Setup Haskell toolchain
if: runner.os != 'Windows'
uses: haskell/ghcup-setup@v1
with:
ghc: '9.12.2'
cabal: '3.10.3.0'
hls: '2.11.0.0'
- name: Verify Haskell tools
if: runner.os != 'Windows'
run: |
echo "Verifying installed Haskell tools:"
which ghc && ghc --version
which cabal && cabal --version
# HLS verification - non-blocking in case of version incompatibility
if command -v haskell-language-server-wrapper &>/dev/null; then
echo "Found haskell-language-server-wrapper"
haskell-language-server-wrapper --version || echo "WARNING: HLS wrapper found but version check failed"
elif command -v haskell-language-server &>/dev/null; then
echo "Found haskell-language-server"
haskell-language-server --version || echo "WARNING: HLS found but version check failed"
else
echo "WARNING: HLS not found (may be incompatible with GHC 9.12.2)"
echo "This is not a critical error - tests will use HLS if available at runtime"
fi
shell: bash
- name: Pre-build Haskell test project for HLS
if: runner.os != 'Windows'
run: |
cd test/resources/repos/haskell/test_repo
cabal update
cabal build --only-dependencies
cabal build
echo "Haskell test project built successfully"
shell: bash
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
+13 -2
View File
@@ -200,6 +200,7 @@ pylint.html
/*.yml
!*.template.yml
/agent-ui
/test/**/.serena
# dynamic LS installations
/src/multilspy/language_servers/*/static
@@ -215,9 +216,11 @@ pylint.html
# Cache is in .multilspy
**/.multilspy/**
# temporary and backup files
*.bak
*.tmp
tmp/
.serena/
.vscode/
# Claude settings
@@ -248,4 +251,12 @@ tmp/
.scala-build/
.bloop/
bootstrap
test/resources/repos/scala/.bloop/
test/resources/repos/scala/.bloop/
# Haskell
.stack-work/
*.cabal
stack.yaml.lock
dist-newstyle/
cabal.project.local*
.ghc.environment.*
+1
View File
@@ -0,0 +1 @@
/cache
+6 -7
View File
@@ -1,11 +1,5 @@
# language of the project (csharp, python, rust, java, typescript, javascript, go, cpp, or ruby)
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: python
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
@@ -64,3 +58,8 @@ excluded_tools: []
initial_prompt: ""
project_name: "serena"
languages:
- python
- typescript
included_optional_tools: []
encoding: utf-8
+35 -9
View File
@@ -1,11 +1,43 @@
# latest
Status of the `main` branch. Changes prior to the next official version change will appear here.
* General:
* Add monorepo/multi-language support
* Project configuration files (`project.yml`) can now define multiple languages.
Auto-detection adds only the most prominent language by default.
* Additional languages can be conveniently added via the Dashboard while a project is already activated.
* Support overloaded symbols in `FindSymbolTool` and related tools
* Name paths of overloaded symbols now include an index (e.g., `myOverloadedFunction[2]`)
* Responses of the Java language server, which handled this in its own way, are now adapted accordingly,
solving several issues related to retrieval problems in Java projects
* Major extensions to the dashboard, which now serves as a central web interface for Serena
* View current configuration
* View the executions, with the possibility to cancel running/scheduled executions
* View tool usage statistics
* View and create memories and edit the serena configuration file
* New two-tier caching of language server document symbols and considerable performance improvements surrounding symbol retrieval/indexing
* Various fixes related to indexing, special paths and determination of ignored paths
* Decreased `TOOL_DEFAULT_MAX_ANSWER_LENGTH` to be in accordance with (below) typical max-tokens configurations
* Allow passing language server specific settings through `ls_specific_settings` field (in `serena_config.yml`)
* Add notion of a "single-project context" (flag `single_project`), allowing user-defined contexts to behave
like the built-in `ide-assistant` context (where the available tools are restricted to ones required by the active
project and project changes are disabled)
* Client support:
* New mode `oaicompat-agent` and extensions enhancing OpenAI tool compatibility, permitting Serena to work with llama.cpp
* Tools:
* Added `RenameSymbolTool` for renaming symbols across the codebase (if LS supports this operation).
* Replaced `ReplaceRegexTool` with `ReplaceContentTool`, which supports both plain text and regex-based replacements
(and which requires no escaping in the replacement text, making it more robust)
* Language support:
* **Add support for Scala** via Metals language server (requires some [manual setup](docs/scala_setup_guide_for_serena.md))
* **Add support for Fortran** via fortls language server (requires `pip install fortls`)
* **Add support for Julia** via LanguageServer.jl
* **Add support for Haskell** via Haskell Language Server (HLS) with automatic discovery via ghcup, stack, or system PATH; supports both Stack and Cabal projects
* **Add support for Scala** via Metals language server (requires some [manual setup](docs/03-special-guides/scala_setup_guide_for_serena.md))
* **Add support for Elm** via @elm-tooling/elm-language-server (automatically downloads if not installed; requires Elm compiler)
* **Add support for Perl** via Perl::LanguageServer with LSP integration for .pl, .pm, and .t files
* **Add support for AL (Application Language)** for Microsoft Dynamics 365 Business Central development. Requires VS Code AL extension (ms-dynamics-smb.al).
@@ -13,19 +45,13 @@ Status of the `main` branch. Changes prior to the next official version change w
* **Add support for Zig** via ZLS (cross-file references may not fully work on Windows)
* **Add support for Lua** via lua-language-server
* **Add support for Nix** requires nixd installation (Windows not supported)
* **Add experimental support for YAML** via yaml-language-server with LSP integration for .yaml and .yml files
* **Dart now officially supported**: Dart was always working, but now tests were added, and it is promoted to "officially supported"
* **Rust now uses already installed rustup**: The rust-analyzer is no longer bundled with Serena. Instead, it uses the rust-analyzer from your Rust toolchain managed by rustup. This ensures compatibility with your Rust version and eliminates outdated bundled binaries.
* **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage.
* **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage.
* **Add support for Erlang** experimental, may hang or be slow, uses the recently archived [erlang_ls](https://github.com/erlang-ls/erlang_ls)
* **Ruby dual language server support**: Added ruby-lsp as the modern primary Ruby language server. Solargraph remains available as an experimental legacy option. ruby-lsp supports both .rb and .erb files, while Solargraph supports .rb files only.
* Client support:
* New mode `oaicompat-agent` and extensions in the openai tool compatibility, **permitting Serena to work with llama.cpp**
* General:
* Various fixes related to indexing, special paths and determation of ignored paths
* Decreased `TOOL_DEFAULT_MAX_ANSWER_LENGTH` to be in accordance with (below) typical max-tokens configurations
* Allow passing language server specific settings through `ls_specific_settings` field (in `serena_config.yml`)
# 0.1.4
+46 -774
View File
@@ -8,46 +8,39 @@
* :wrench: Serena provides essential **semantic code retrieval and editing tools** that are akin to an IDE's capabilities, extracting code entities at the symbol level and exploiting relational structure. When combined with an existing coding agent, these tools greatly enhance (token) efficiency.
* :free: Serena is **free & open-source**, enhancing the capabilities of LLMs you already have access to free of charge.
You can think of Serena as providing IDE-like tools to your LLM/coding agent. With it, the agent no longer needs to read entire
files, perform grep-like searches or string replacements to find and edit the right code. Instead, it can use code centered tools like `find_symbol`, `find_referencing_symbols` and `insert_after_symbol`.
You can think of Serena as providing IDE-like tools to your LLM/coding agent.
With it, the agent no longer needs to read entire files, perform grep-like searches or basic string replacements to find the right parts of the code and to edit code.
Instead, it can use code-centric tools like `find_symbol`, `find_referencing_symbols` and `insert_after_symbol`.
<p align="center">
<em>Serena is under active development! See the latest updates, upcoming features, and lessons learned to stay up to date.</em>
</p>
<p align="center">
<a href="CHANGELOG.md">
<img src="https://img.shields.io/badge/Updates-1e293b?style=flat&logo=rss&logoColor=white&labelColor=1e293b" alt="Changelog" />
</a>
<a href="roadmap.md">
<img src="https://img.shields.io/badge/Roadmap-14532d?style=flat&logo=target&logoColor=white&labelColor=14532d" alt="Roadmap" />
</a>
<a href="lessons_learned.md">
<img src="https://img.shields.io/badge/Lessons-Learned-7c4700?style=flat&logo=readthedocs&logoColor=white&labelColor=7c4700" alt="Lessons Learned" />
</a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/Updates-1e293b?style=flat&logo=rss&logoColor=white&labelColor=1e293b" alt="Changelog" /></a>
<a href="roadmap.md"><img src="https://img.shields.io/badge/Roadmap-14532d?style=flat&logo=target&logoColor=white&labelColor=14532d" alt="Roadmap" /></a>
<a href="lessons_learned.md"><img src="https://img.shields.io/badge/Lessons-Learned-7c4700?style=flat&logo=readthedocs&logoColor=white&labelColor=7c4700" alt="Lessons Learned" /></a>
</p>
### LLM Integration
## LLM Integration
Serena provides the necessary [tools](#list-of-tools) for coding workflows, but an LLM is required to do the actual work,
Serena provides the necessary [tools](https://oraios.github.io/serena/01-about/035_tools.html) for coding workflows, but an LLM is required to do the actual work,
orchestrating tool use.
For example, **supercharge the performance of Claude Code** with a [one-line shell command](#claude-code).
In general, Serena can be integrated with an LLM in several ways:
* by using the **model context protocol (MCP)**.
Serena provides an MCP server which integrates with
* Claude Code and Claude Desktop,
* Terminal-based clients like Codex, Gemini-CLI, Qwen3-Coder, rovodev, OpenHands CLI and others,
* terminal-based clients like Codex, Gemini-CLI, Qwen3-Coder, rovodev, OpenHands CLI and others,
* IDEs like VSCode, Cursor or IntelliJ,
* Extensions like Cline or Roo Code
* Local clients like [OpenWebUI](https://docs.openwebui.com/openapi-servers/mcp), [Jan](https://jan.ai/docs/mcp-examples/browser/browserbase#enable-mcp), [Agno](https://docs.agno.com/introduction/playground) and others
* by using [mcpo to connect it to ChatGPT](docs/serena_on_chatgpt.md) or other clients that don't support MCP but do support tool calling via OpenAPI.
* by incorporating Serena's tools into an agent framework of your choice, as illustrated [here](docs/custom_agent.md).
* by using [mcpo to connect it to ChatGPT](docs/03-special-guides/serena_on_chatgpt.md) or other clients that don't support MCP but do support tool calling via OpenAPI.
* by incorporating Serena's tools into an agent framework of your choice, as illustrated [here](docs/03-special-guides/custom_agent.md).
Serena's tool implementation is decoupled from the framework-specific code and can thus easily be adapted to any agent framework.
### Serena in Action
## Serena in Action
#### Demonstration 1: Efficient Operation in Claude Code
@@ -74,44 +67,45 @@ complex projects! So not only is it free and open-source, it frequently achieves
than existing solutions that charge a premium.
Language servers provide support for a wide range of programming languages.
With Serena, we provide direct, out-of-the-box support for:
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, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, Python, R, Ruby, Rust, Scala, Swift, TypeScript, YAML and Zig.
* Python
* TypeScript/Javascript
* PHP (uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features)
* Go (requires installation of gopls)
* R (requires installation of the `languageserver` R package)
* Rust (requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain)
* C/C++ (you may experience issues with finding references, we are working on it)
* Zig (requires installation of ZLS - Zig Language Server)
* C#
* Ruby (by default, uses [ruby-lsp](https://github.com/Shopify/ruby-lsp), specify ruby_solargraph as your language to use the previous solargraph based implementation)
* Swift
* Kotlin (uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear)
* Java (_Note_: startup is slow, initial startup especially so. There may be issues with java on macos and linux, we are working on it.)
* Clojure
* Dart
* Bash
* Lua (automatically downloads lua-language-server if not installed)
* Nix (requires nixd installation)
* Elixir (requires installation of NextLS and Elixir; **Windows not supported**)
* Elm (automatically downloads elm-language-server if not installed; requires Elm compiler)
* Scala (requires some [manual setup](docs/scala_setup_guide_for_serena.md); uses Metals LSP)
* Erlang (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls), experimental, might be slow or hang)
* Perl (requires installation of Perl::LanguageServer)
* AL
* Markdown (must be explicitly specified via `--language markdown` when generating project config, primarily useful for documentation-heavy projects)
> [!IMPORTANT]
> Some languages require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details.
Support for further languages can easily be added by providing a shallow adapter for a new language server implementation,
see Serena's [memory on that](.serena/memories/adding_new_language_support_guide.md).
## Quick Start
### Community Feedback
**Prerequisites**. Serena is managed by *uv*. If you don’t already have it, you need to [install uv](https://docs.astral.sh/uv/getting-started/installation/) before proceeding.
**Starting the MCP Server**. The easiest way to start the Serena MCP server is by running the latest version from GitHub using uvx.
Issue this command to see available options:
```bash
uvx --from git+https://github.com/oraios/serena serena start-mcp-server --help
```
**Configuring Your Client**. To connect Serena to your preferred MCP client, you typically need to [configure a launch command in your client](https://oraios.github.io/serena/02-usage/030_clients.html).
Follow the link for specific instructions on how to set up Serena for Claude Code, Codex, Claude Desktop, MCP-enabled IDEs and other clients (such as local and web-based GUIs).
> [!TIP]
> While getting started quickly is easy, Serena is a powerful toolkit with many configuration options.
> We highly recommend reading through the [user guide](https://oraios.github.io/serena/02-usage/000_intro.html) to get the most out of Serena.
>
> Specifically, we recommend to read about ...
> * [Serena's project-based workflow](https://oraios.github.io/serena/02-usage/040_workflow.html) and
> * [configuring Serena](https://oraios.github.io/serena/02-usage/050_configuration.html).
## User Guide
Please refer to the [user guide](https://oraios.github.io/serena/02-usage/000_intro.html) for detailed instructions on how to use Serena effectively.
## Community Feedback
Most users report that Serena has strong positive effects on the results of their coding agents, even when used within
very capable agents like Claude Code. Serena is often described to be a [game changer](https://www.reddit.com/r/ClaudeAI/comments/1lfsdll/try_out_serena_mcp_thank_me_later/), providing an enormous [productivity boost](https://www.reddit.com/r/ClaudeCode/comments/1mguoia/absolutely_insane_improvement_of_claude_code).
Serena excels at navigating and manipulating complex codebases, providing tools that support precise code retrieval and editing in the presence of large, strongly structured codebases.
However, when dealing with tasks that involve only very few/small files, you may not benefit from including Serena on top of your existing coding agent.
However, when dealing with tasks that involve only very few/small files, you may not benefit from including Serena on top of your existing coding agent.
In particular, when writing code from scratch, Serena will not provide much value initially, as the more complex structures that Serena handles more gracefully than simplistic, file-based approaches are yet to be created.
Several videos and blog posts have talked about Serena:
@@ -126,673 +120,6 @@ Several videos and blog posts have talked about Serena:
* [Serena with Claude Code (in Japanese)](https://blog.lai.so/serena/)
* [Turning Claude Code into a Development Powerhouse](https://robertmarshall.dev/blog/turning-claude-code-into-a-development-powerhouse/)
## Table of Contents
<!-- Created with markdown-toc -i README.md -->
<!-- Install it with npm install -g markdown-toc -->
<!-- toc -->
- [Quick Start](#quick-start)
* [Running the Serena MCP Server](#running-the-serena-mcp-server)
+ [Usage](#usage)
+ [Using uvx](#using-uvx)
+ [Local Installation](#local-installation)
+ [Using Docker (Experimental)](#using-docker-experimental)
+ [Using Nix](#using-nix)
+ [Streamable HTTP Mode](#streamable-http-mode)
+ [Command-Line Arguments](#command-line-arguments)
* [Configuration](#configuration)
* [Project Activation & Indexing](#project-activation--indexing)
* [Claude Code](#claude-code)
* [Codex](#codex)
* [Other Terminal-Based Clients](#other-terminal-based-clients)
* [Claude Desktop](#claude-desktop)
* [MCP Coding Clients (Cline, Roo-Code, Cursor, Windsurf, etc.)](#mcp-coding-clients-cline-roo-code-cursor-windsurf-etc)
* [Local GUIs and Frameworks](#local-guis-and-frameworks)
- [Detailed Usage and Recommendations](#detailed-usage-and-recommendations)
* [Tool Execution](#tool-execution)
+ [Shell Execution and Editing Tools](#shell-execution-and-editing-tools)
* [Modes and Contexts](#modes-and-contexts)
+ [Contexts](#contexts)
+ [Modes](#modes)
+ [Customization](#customization)
* [Onboarding and Memories](#onboarding-and-memories)
* [Prepare Your Project](#prepare-your-project)
+ [Structure Your Codebase](#structure-your-codebase)
+ [Start from a Clean State](#start-from-a-clean-state)
+ [Logging, Linting, and Automated Tests](#logging-linting-and-automated-tests)
* [Prompting Strategies](#prompting-strategies)
* [Running Out of Context](#running-out-of-context)
* [Serena's Logs: The Dashboard and GUI Tool](#serenas-logs-the-dashboard-and-gui-tool)
* [Serena and GIT worktrees](#serena-and-git-worktrees)
- [Comparison with Other Coding Agents](#comparison-with-other-coding-agents)
* [Subscription-Based Coding Agents](#subscription-based-coding-agents)
* [API-Based Coding Agents](#api-based-coding-agents)
* [Other MCP-Based Coding Agents](#other-mcp-based-coding-agents)
- [Acknowledgements](#acknowledgements)
* [Sponsors](#sponsors)
* [Community Contributions](#community-contributions)
* [Technologies](#technologies)
- [Customizing and Extending Serena](#customizing-and-extending-serena)
- [List of Tools](#list-of-tools)
<!-- tocstop -->
## Quick Start
Serena can be used in various ways, below you will find instructions for selected integrations.
* For coding with Claude, we recommend using Serena through [Claude Code](#claude-code) or [Claude Desktop](#claude-desktop). You can also use Serena in most other [terminal-based clients](#other-terminal-based-clients).
* If you want a GUI experience outside an IDE, you can use one of the many [local GUIs](#local-guis-and-frameworks) that support MCP servers.
You can also connect Serena to many web clients (including ChatGPT) using [mcpo](docs/serena_on_chatgpt.md).
* If you want to use Serena integrated in your IDE, see the section on [other MCP clients](#other-mcp-clients---cline-roo-code-cursor-windsurf-etc).
* You can use Serena as a library for building your own applications. We try to keep the public API stable, but you should still
expect breaking changes and pin Serena to a fixed version if you use it as a dependency.
Serena is managed by `uv`, so you will need to [install it](https://docs.astral.sh/uv/getting-started/installation/).
### Running the Serena MCP Server
You have several options for running the MCP server, which are explained in the subsections below.
#### Usage
The typical usage involves the client (Claude Code, Claude Desktop, etc.) running
the MCP server as a subprocess (using stdio communication),
so the client needs to be provided with the command to run the MCP server.
(Alternatively, you can run the MCP server in Streamable HTTP or SSE mode and tell your client
how to connect to it.)
Note that no matter how you run the MCP server, Serena will, by default, start a small web-based dashboard on localhost that will display logs and allow shutting down the
MCP server (since many clients fail to clean up processes correctly).
This and other settings can be adjusted in the [configuration](#configuration) and/or by providing [command-line arguments](#command-line-arguments).
#### Using uvx
`uvx` can be used to run the latest version of Serena directly from the repository, without an explicit local installation.
```shell
uvx --from git+https://github.com/oraios/serena serena start-mcp-server
```
Explore the CLI to see some of the customization options that serena provides (more info on them below).
#### Local Installation
1. Clone the repository and change into it.
```shell
git clone https://github.com/oraios/serena
cd serena
```
2. Optionally edit the configuration file in your home directory with
```shell
uv run serena config edit
```
If you just want the default config, you can skip this part, and a config file will be created when you first run Serena.
3. Run the server with `uv`:
```shell
uv run serena start-mcp-server
```
When running from outside the serena installation directory, be sure to pass it, i.e., use
```shell
uv run --directory /abs/path/to/serena serena start-mcp-server
```
#### Using Docker (Experimental)
⚠️ Docker support is currently experimental with several limitations. Please read the [Docker documentation](DOCKER.md) for important caveats before using it.
You can run the Serena MCP server directly via docker as follows,
assuming that the projects you want to work on are all located in `/path/to/your/projects`:
```shell
docker run --rm -i --network host -v /path/to/your/projects:/workspaces/projects ghcr.io/oraios/serena:latest serena start-mcp-server --transport stdio
```
Replace `/path/to/your/projects` with the absolute path to your projects directory. The Docker approach provides:
* Better security isolation for shell command execution
* No need to install language servers and dependencies locally
* Consistent environment across different systems
Alternatively, use docker compose with the `compose.yml` file provided in the repository.
See the [Docker documentation](DOCKER.md) for detailed setup instructions, configuration options, and known limitations.
#### Using Nix
If you are using Nix and [have enabled the `nix-command` and `flakes` features](https://nixos.wiki/wiki/flakes), you can run Serena using the following command:
```bash
nix run github:oraios/serena -- start-mcp-server --transport stdio
```
You can also install Serena by referencing this repo (`github:oraios/serena`) and using it in your Nix flake. The package is exported as `serena`.
#### Streamable HTTP Mode
ℹ️ Note that MCP servers which use stdio as a protocol are somewhat unusual as far as client/server architectures go, as the server
necessarily has to be started by the client in order for communication to take place via the server's standard input/output stream.
In other words, you do not need to start the server yourself. The client application (e.g. Claude Desktop) takes care of this and
therefore needs to be configured with a launch command.
When using instead the *Streamable HTTP* mode, you control the server lifecycle yourself,
i.e. you start the server and provide the client with the URL to connect to it.
Simply provide `start-mcp-server` with the `--transport streamable-http` option and optionally provide the port.
For example, to run the Serena MCP server in Streamable HTTP mode on port 9121 using a local installation,
you would run this command from the Serena directory,
```shell
uv run serena start-mcp-server --transport streamable-http --port 9121
```
and then configure your client to connect to `http://localhost:9121/mcp`.
ℹ️ Note that SSE transport is supported as well, but its use is discouraged.
Use Streamable HTTP instead.
#### Command-Line Arguments
The Serena MCP server supports a wide range of additional command-line options, including the option to run in Streamable HTTP or SSE mode
and to adapt Serena to various [contexts and modes of operation](#modes-and-contexts).
Run with parameter `--help` to get a list of available options.
### Configuration
Serena is very flexible in terms of configuration. While for most users, the default configurations will work,
you can fully adjust it to your needs by editing a few yaml files. You can disable tools, change Serena's instructions
(what we denote as the `system_prompt`), adjust the output of tools that just provide a prompt, and even adjust tool descriptions.
Serena is configured in four places:
1. The `serena_config.yml` for general settings that apply to all clients and projects.
It is located in your user directory under `.serena/serena_config.yml`.
If you do not explicitly create the file, it will be auto-generated when you first run Serena.
You can edit it directly or use
```shell
uvx --from git+https://github.com/oraios/serena serena config edit
```
(or use the `--directory` command version).
2. In the arguments passed to the `start-mcp-server` in your client's config (see below),
which will apply to all sessions started by the respective client. In particular, the [context](#contexts) parameter
should be set appropriately for Serena to be best adjusted to existing tools and capabilities of your client.
See for a detailed explanation. You can override all entries from the `serena_config.yml` through command line arguments.
3. In the `.serena/project.yml` file within your project. This will hold project-level configuration that is used whenever
that project is activated. This file will be autogenerated when you first use Serena on that project, but you can also
generate it explicitly with
```shell
uvx --from git+https://github.com/oraios/serena serena project generate-yml
```
(or use the `--directory` command version).
4. Through the context and modes. Explore the [modes and contexts](#modes-and-contexts) section for more details.
After the initial setup, continue with one of the sections below, depending on how you
want to use Serena.
### Project Activation & Indexing
If you are mostly working with the same project, you can configure to always activate it at startup
by passing `--project <path_or_name>` to the `start-mcp-server` command in your client's MCP config.
This is especially useful for clients which configure MCP servers on a per-project basis, like Claude Code.
Otherwise, the recommended way is to just ask the LLM to activate a project by providing it an absolute path to, or,
in case the project was activated in the past, by its name. The default project name is the directory name.
* "Activate the project /path/to/my_project"
* "Activate the project my_project"
All projects that have been activated will be automatically added to your `serena_config.yml`, and for each
project, the file `.serena/project.yml` will be generated. You can adjust the latter, e.g., by changing the name
(which you refer to during the activation) or other options. Make sure to not have two different projects with the
same name.
ℹ️ For larger projects, we recommend that you index your project to accelerate Serena's tools; otherwise the first
tool application may be very slow.
To do so, run this from the project directory (or pass the path to the project as an argument):
```shell
uvx --from git+https://github.com/oraios/serena serena project index
```
(or use the `--directory` command version).
### Claude Code
Serena is a great way to make Claude Code both cheaper and more powerful!
From your project directory, add serena with a command like this,
```shell
claude mcp add serena -- <serena-mcp-server> --context ide-assistant --project "$(pwd)"
```
where `<serena-mcp-server>` is your way of [running the Serena MCP server](#running-the-serena-mcp-server).
For example, when using `uvx`, you would run
```shell
claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena start-mcp-server --context ide-assistant --project "$(pwd)"
```
ℹ️ Serena comes with an instruction text, and Claude needs to read it to properly use Serena's tools.
As of version `v1.0.52`, claude code reads the instructions of the MCP server, so this **is handled automatically**.
If you are using an older version, or if Claude fails to read the instructions, you can ask it explicitly
to "read Serena's initial instructions" or run `/mcp__serena__initial_instructions` to load the instruction text.
If you want to make use of that, you will have to enable the corresponding tool explicitly by adding `initial_instructions` to the `included_optional_tools`
in your config.
Note that you may have to make Claude read the instructions when you start a new conversation and after any compacting operation to ensure Claude remains properly configured to use Serena's tools.
### Codex
Serena works with OpenAI's Codex CLI out of the box, but you have to use the `codex` context for it to work properly. (The technical reason is that Codex doesn't fully support the MCP specifications, so some massaging of tools is required.).
Unlike Claude Code, in Codex you add an MCP server globally and not per project. Add the following to
`~/.codex/config.toml` (create the file if it does not exist):
```toml
[mcp_servers.serena]
command = "uvx"
args = ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server", "--context", "codex"]
```
After codex has started, you need to activate the project, which you can do by saying:
"Activate the current dir as project using serena"
> If you don't activate the project, you will not be able to use Serena's tools!
That's it! Have a look at `~/.codex/log/codex-tui.log` to see if any errors occurred.
The Serena dashboard will run if you have not disabled it in the configuration, but due to Codex's sandboxing the webbrowser
may not open automatically. You can open it manually by going to `http://localhost:24282/dashboard/index.html` (or a higher port, if
that was already taken).
> Codex will often show the tools as `failed` even though they are successfully executed. This is not a problem, seems to be a bug in Codex. Despite the error message, everything works as expected.
### Other Terminal-Based Clients
There are many terminal-based coding assistants that support MCP servers, such as [Codex](https://github.com/openai/codex?tab=readme-ov-file#model-context-protocol-mcp),
[Gemini-CLI](https://github.com/google-gemini/gemini-cli), [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder),
[rovodev](https://community.atlassian.com/forums/Rovo-for-Software-Teams-Beta/Introducing-Rovo-Dev-CLI-AI-Powered-Development-in-your-terminal/ba-p/3043623),
the [OpenHands CLI](https://docs.all-hands.dev/usage/how-to/cli-mode) and [opencode](https://github.com/sst/opencode).
They generally benefit from the symbolic tools provided by Serena. You might want to customize some aspects of Serena
by writing your own context, modes or prompts to adjust it to your workflow, to other MCP servers you are using, and to
the client's internal capabilities.
### Claude Desktop
For [Claude Desktop](https://claude.ai/download) (available for Windows and macOS), go to File / Settings / Developer / MCP Servers / Edit Config,
which will let you open the JSON file `claude_desktop_config.json`.
Add the `serena` MCP server configuration, using a [run command](#running-the-serena-mcp-server) depending on your setup.
* local installation:
```json
{
"mcpServers": {
"serena": {
"command": "/abs/path/to/uv",
"args": ["run", "--directory", "/abs/path/to/serena", "serena", "start-mcp-server"]
}
}
}
```
* uvx:
```json
{
"mcpServers": {
"serena": {
"command": "/abs/path/to/uvx",
"args": ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"]
}
}
}
```
* docker:
```json
{
"mcpServers": {
"serena": {
"command": "docker",
"args": ["run", "--rm", "-i", "--network", "host", "-v", "/path/to/your/projects:/workspaces/projects", "ghcr.io/oraios/serena:latest", "serena", "start-mcp-server", "--transport", "stdio"]
}
}
}
```
If you are using paths containing backslashes for paths on Windows
(note that you can also just use forward slashes), be sure to escape them correctly (`\\`).
That's it! Save the config and then restart Claude Desktop. You are ready for activating your first project.
ℹ️ You can further customize the run command using additional arguments (see [above](#command-line-arguments)).
Note: on Windows and macOS there are official Claude Desktop applications by Anthropic, for Linux there is an [open-source
community version](https://github.com/aaddrick/claude-desktop-debian).
⚠️ Be sure to fully quit the Claude Desktop application, as closing Claude will just minimize it to the system tray – at least on Windows.
⚠️ Some clients may leave behind zombie processes. You will have to find and terminate them manually then.
With Serena, you can activate the [dashboard](#serenas-logs-the-dashboard-and-gui-tool) to prevent unnoted processes and also use the dashboard
for shutting down Serena.
After restarting, you should see Serena's tools in your chat interface (notice the small hammer icon).
For more information on MCP servers with Claude Desktop, see [the official quick start guide](https://modelcontextprotocol.io/quickstart/user).
### MCP Coding Clients (Cline, Roo-Code, Cursor, Windsurf, etc.)
Being an MCP Server, Serena can be included in any MCP Client. The same configuration as above,
perhaps with small client-specific modifications, should work. Most of the popular
existing coding assistants (IDE extensions or VSCode-like IDEs) support connections
to MCP Servers. It is **recommended to use the `ide-assistant` context** for these integrations by adding `"--context", "ide-assistant"` to the `args` in your MCP client's configuration. Including Serena generally boosts their performance
by providing them tools for symbolic operations.
In this case, the billing for the usage continues to be controlled by the client of your choice
(unlike with the Claude Desktop client). But you may still want to use Serena through such an approach,
e.g., for one of the following reasons:
1. You are already using a coding assistant (say Cline or Cursor) and just want to make it more powerful.
2. You are on Linux and don't want to use the [community-created Claude Desktop](https://github.com/aaddrick/claude-desktop-debian).
3. You want tighter integration of Serena into your IDE and don't mind paying for that.
### Local GUIs and Frameworks
Over the last months, several technologies have emerged that allow you to run a powerful local GUI
and connect it to an MCP server. They will work with Serena out of the box.
Some of the leading open source GUI technologies offering this are
[Jan](https://jan.ai/docs/mcp), [OpenHands](https://github.com/All-Hands-AI/OpenHands/),
[OpenWebUI](https://docs.openwebui.com/openapi-servers/mcp) and [Agno](https://docs.agno.com/introduction/playground).
They allow combining Serena with almost any LLM (including locally running ones) and offer various other integrations.
## Detailed Usage and Recommendations
### Tool Execution
Serena combines tools for semantic code retrieval with editing capabilities and shell execution.
Serena's behavior can be further customized through [Modes and Contexts](#modes-and-contexts).
Find the complete list of tools [below](#full-list-of-tools).
The use of all tools is generally recommended, as this allows Serena to provide the most value:
Only by executing shell commands (in particular, tests) can Serena identify and correct mistakes
autonomously.
#### Shell Execution and Editing Tools
Many clients have their own shell execution tool, and by default Serena's shell tool will be disabled in them
(e.g., when using the `ide-assistant` or `codex` context). However, when using Serena through something like
Claude Desktop or ChatGPT, it is recommended to enable Serena's `execute_shell_command` tool to allow
agentic behavior.
It should be noted that the `execute_shell_command` tool allows for arbitrary code execution.
When using Serena as an MCP Server, clients will typically ask the user for permission
before executing a tool, so as long as the user inspects execution parameters beforehand,
this should not be a problem.
However, if you have concerns, you can choose to disable certain commands in your project's configuration file.
If you only want to use Serena purely for analyzing code and suggesting implementations
without modifying the codebase, you can enable read-only mode by setting `read_only: true` in your project configuration file.
This will automatically disable all editing tools and prevent any modifications to your codebase while still
allowing all analysis and exploration capabilities.
In general, be sure to back up your work and use a version control system in order to avoid
losing any work.
### Modes and Contexts
Serena's behavior and toolset can be adjusted using contexts and modes.
These allow for a high degree of customization to best suit your workflow and the environment Serena is operating in.
#### Contexts
A context defines the general environment in which Serena is operating.
It influences the initial system prompt and the set of available tools.
A context is set at startup when launching Serena (e.g., via CLI options for an MCP server or in the agent script) and cannot be changed during an active session.
Serena comes with pre-defined contexts:
* `desktop-app`: Tailored for use with desktop applications like Claude Desktop. This is the default.
* `agent`: Designed for scenarios where Serena acts as a more autonomous agent, for example, when used with Agno.
* `ide-assistant`: Optimized for integration into IDEs like VSCode, Cursor, or Cline, focusing on in-editor coding assistance.
Choose the context that best matches the type of integration you are using.
When launching Serena, specify the context using `--context <context-name>`.
Note that for cases where parameter lists are specified (e.g. Claude Desktop), you must add two parameters to the list.
If you are using a local server (such as Llama.cpp) which requires you to use OpenAI-compatible tool descriptions, use context `oaicompat-agent` instead of `agent`.
#### Modes
Modes further refine Serena's behavior for specific types of tasks or interaction styles. Multiple modes can be active simultaneously, allowing you to combine their effects. Modes influence the system prompt and can also alter the set of available tools by excluding certain ones.
Examples of built-in modes include:
* `planning`: Focuses Serena on planning and analysis tasks.
* `editing`: Optimizes Serena for direct code modification tasks.
* `interactive`: Suitable for a conversational, back-and-forth interaction style.
* `one-shot`: Configures Serena for tasks that should be completed in a single response, often used with `planning` for generating reports or initial plans.
* `no-onboarding`: Skips the initial onboarding process if it's not needed for a particular session.
* `onboarding`: (Usually triggered automatically) Focuses on the project onboarding process.
Modes can be set at startup (similar to contexts) but can also be _switched dynamically_ during a session. You can instruct the LLM to use the `switch_modes` tool to activate a different set of modes (e.g., "switch to planning and one-shot modes").
When launching Serena, specify modes using `--mode <mode-name>`; multiple modes can be specified, e.g. `--mode planning --mode no-onboarding`.
:warning: **Mode Compatibility**: While you can combine modes, some may be semantically incompatible (e.g., `interactive` and `one-shot`). Serena currently does not prevent incompatible combinations; it is up to the user to choose sensible mode configurations.
#### Customization
You can create your own contexts and modes to precisely tailor Serena to your needs in two ways:
* You can use Serena's CLI to manage modes and contexts. Check out
```shell
uvx --from git+https://github.com/oraios/serena serena mode --help
```
and
```shell
uvx --from git+https://github.com/oraios/serena serena context --help
```
_NOTE_: Custom contexts/modes are simply YAML files in `<home>/.serena`, they are automatically registered and available for use by their name (filename without the `.yml` extension). If you don't want to use Serena's CLI, you can create and manage them in any way you see fit.
* **Using external YAML files**: When starting Serena, you can also provide an absolute path to a custom `.yml` file for a context or mode.
This customization allows for deep integration and adaptation of Serena to specific project requirements or personal preferences.
### Onboarding and Memories
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.
**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.
### Prepare Your Project
#### Structure Your Codebase
Serena uses the code structure for finding, reading and editing code. This means that it will
work well with well-structured code but may perform poorly on fully unstructured one (like a "God class"
with enormous, non-modular functions).
Furthermore, for languages that are not statically typed, type annotations are highly beneficial.
#### Start from a Clean State
It is best to start a code generation task from a clean git state. Not only will
this make it easier for you to inspect the changes, but also the model itself will
have a chance of seeing what it has changed by calling `git diff` and thereby
correct itself or continue working in a followup conversation if needed.
:warning: **Important**: since Serena will write to files using the system-native line endings
and it might want to look at the git diff, it is important to
set `git config core.autocrlf` to `true` on Windows.
With `git config core.autocrlf` set to `false` on Windows, you may end up with huge diffs
only due to line endings. It is generally a good idea to globally enable this git setting on Windows:
```shell
git config --global core.autocrlf true
```
#### Logging, Linting, and Automated Tests
Serena can successfully complete tasks in an _agent loop_, where it iteratively
acquires information, performs actions, and reflects on the results.
However, Serena cannot use a debugger; it must rely on the results of program executions,
linting results, and test results to assess the correctness of its actions.
Therefore, software that is designed to meaningful interpretable outputs (e.g. log messages)
and that has a good test coverage is much easier to work with for Serena.
We generally recommend to start an editing task from a state where all linting checks and tests pass.
### Prompting Strategies
We found that it is often a good idea to spend some time conceptualizing and planning a task
before actually implementing it, especially for non-trivial task. This helps both in achieving
better results and in increasing the feeling of control and staying in the loop. You can
make a detailed plan in one session, where Serena may read a lot of your code to build up the context,
and then continue with the implementation in another (potentially after creating suitable memories).
### Running Out of Context
For long and complicated tasks, or tasks where Serena has read a lot of content, you
may come close to the limits of context tokens. In that case, it is often a good idea to continue
in a new conversation. Serena has a dedicated tool to create a summary of the current state
of the progress and all relevant info for continuing it. You can request to create this summary and
write it to a memory. Then, in a new conversation, you can just ask Serena to read the memory and
continue with the task. In our experience, this worked really well. On the up-side, since in a
single session there is no summarization involved, Serena does not usually get lost (unlike some
other agents that summarize under the hood), and it is also instructed to occasionally check whether
it's on the right track.
Moreover, Serena is instructed to be frugal with context
(e.g., to not read bodies of code symbols unnecessarily),
but we found that Claude is not always very good in being frugal (Gemini seemed better at it).
You can explicitly instruct it to not read the bodies if you know that it's not needed.
### Serena's Logs: The Dashboard and GUI Tool
Serena provides two convenient ways of accessing the logs of the current session:
* via the **web-based dashboard** (enabled by default)
This is supported on all platforms.
By default, it will be accessible at `http://localhost:24282/dashboard/index.html`,
but a higher port may be used if the default port is unavailable/multiple instances are running.
* via the **GUI tool** (disabled by default)
This is mainly supported on Windows, but it may also work on Linux; macOS is unsupported.
Both can be enabled, configured or disabled in Serena's configuration file (`serena_config.yml`, see above).
If enabled, they will automatically be opened as soon as the Serena agent/MCP server is started.
The web dashboard will display usage statistics of Serena's tools if you set `record_tool_usage_stats: True` in your config.
In addition to viewing logs, both tools allow to shut down the Serena agent.
This function is provided, because clients like Claude Desktop may fail to terminate the MCP server subprocess
when they themselves are closed.
### Serena and GIT worktrees
[git-worktree](https://git-scm.com/docs/git-worktree) can be an excellent way to parallelize your work. More on this in [Anthropic: Run parallel Claude Code sessions with Git worktrees](https://docs.claude.com/en/docs/claude-code/common-workflows#run-parallel-claude-code-sessions-with-git-worktrees).
When it comes to serena AND git-worktree AND larger projects (that take longer to index), the recommended way is to COPY your `$ORIG_PROJECT/.serena/cache` to `$GIT_WORKTREE/.serena/cache`. After you have performed pre-indexing of your project described in [Project Activation & Indexing](#project-activation--indexing) section. To avoid having to re-index per each git work tree that you create.
## Comparison with Other Coding Agents
To our knowledge, Serena is the first fully-featured coding agent where the
entire functionality
is available through an MCP server, thus not requiring API keys or
subscriptions.
### Subscription-Based Coding Agents
Many prominent subscription-based coding agents are parts of IDEs like
Windsurf, Cursor and VSCode.
Serena's functionality is similar to Cursor's Agent, Windsurf's Cascade or
VSCode's agent mode.
Serena has the advantage of not requiring a subscription.
A potential disadvantage is that it
is not directly integrated into an IDE, so the inspection of newly written code
is not as seamless.
More technical differences are:
* Serena is not bound to a specific IDE or CLI.
Serena's MCP server can be used with any MCP client (including some IDEs),
and the Agno-based agent provides additional ways of applying its functionality.
* Serena is not bound to a specific large language model or API.
* Serena navigates and edits code using a language server, so it has a symbolic
understanding of the code.
IDE-based tools often use a RAG-based or purely text-based approach, which is often
less powerful, especially for large codebases.
* Serena is open-source and has a small codebase, so it can be easily extended
and modified.
### API-Based Coding Agents
An alternative to subscription-based agents are API-based agents like Claude
Code, Cline, Aider, Roo Code and others, where the usage costs map directly
to the API costs of the underlying LLM.
Some of them (like Cline) can even be included in IDEs as an extension.
They are often very powerful and their main downside are the (potentially very
high) API costs.
Serena itself can be used as an API-based agent (see the section on Agno above).
We have not yet written a CLI tool or a
dedicated IDE extension for Serena (and there is probably no need for the latter, as
Serena can already be used with any IDE that supports MCP servers).
If there is demand for a Serena as a CLI tool like Claude Code, we will
consider writing one.
The main difference between Serena and other API-based agents is that Serena can
also be used as an MCP server, thus not requiring
an API key and bypassing the API costs. This is a unique feature of Serena.
### Other MCP-Based Coding Agents
There are other MCP servers designed for coding, like [DesktopCommander](https://github.com/wonderwhy-er/DesktopCommanderMCP) and
[codemcp](https://github.com/ezyang/codemcp).
However, to the best of our knowledge, none of them provide semantic code
retrieval and editing tools; they rely purely on text-based analysis.
It is the integration of language servers and the MCP that makes Serena unique
and so powerful for challenging coding tasks, especially in the context of
larger codebases.
## Acknowledgements
### Sponsors
@@ -822,16 +149,12 @@ what it is today.
We built Serena on top of multiple existing open-source technologies, the most important ones being:
1. [multilspy](https://github.com/microsoft/multilspy).
A library which wraps language server implementations and adapts them for interaction via Python
and which provided the basis for our library Solid-LSP (src/solidlsp).
A library which wraps language server implementations and adapts them for interaction via Python.
It provided the basis for our library Solid-LSP (`src/solidlsp`).
Solid-LSP provides pure synchronous LSP calls and extends the original library with the symbolic logic
that Serena required.
2. [Python MCP SDK](https://github.com/modelcontextprotocol/python-sdk)
3. [Agno](https://github.com/agno-agi/agno) and
the associated [agent-ui](https://github.com/agno-agi/agent-ui),
which we use to allow Serena to work with any model, beyond the ones
supporting the MCP.
4. All the language servers that we use through Solid-LSP.
3. All the language servers that we use through Solid-LSP.
Without these projects, Serena would not have been possible (or would have been significantly more difficult to build).
@@ -847,54 +170,3 @@ It is also relatively straightforward to add [support for a new programming lang
We look forward to seeing what the community will come up with!
For details on contributing, see [contributing guidelines](/CONTRIBUTING.md).
## List of Tools
Here is the list of Serena's default tools with a short description (output of `uv run serena tools list`):
* `activate_project`: Activates a project based on the project name or path.
* `check_onboarding_performed`: Checks whether project onboarding was already performed.
* `create_text_file`: Creates/overwrites a file in the project directory.
* `delete_memory`: Deletes a memory from Serena's project-specific memory store.
* `execute_shell_command`: Executes a shell command.
* `find_file`: Finds files in the given relative paths
* `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
* `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
* `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
* `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
* `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
* `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
* `list_dir`: Lists files and directories in the given directory (optionally with recursion).
* `list_memories`: Lists memories in Serena's project-specific memory store.
* `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
* `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
* `read_file`: Reads a file within the project directory.
* `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
* `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
* `replace_regex`: Replaces content in a file by using regular expressions.
* `replace_symbol_body`: Replaces the full definition of a symbol.
* `search_for_pattern`: Performs a search for a pattern in the project.
* `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
* `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
* `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
* `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
There are several tools that are disabled by default, and have to be enabled explicitly, e.g., through the context or modes.
Note that several of our default contexts do enable some of these tools. For example, the `desktop-app` context enables the `execute_shell_command` tool.
The full list of optional tools is (output of `uv run serena tools list --only-optional`):
* `delete_lines`: Deletes a range of lines within a file.
* `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
* `initial_instructions`: Gets the initial instructions for the current project.
Should only be used in settings where the system prompt cannot be set,
e.g. in clients you have no control over, like Claude Desktop.
* `insert_at_line`: Inserts content at a given line in a file.
* `jet_brains_find_referencing_symbols`: Finds symbols that reference the given symbol
* `jet_brains_find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
* `jet_brains_get_symbols_overview`: Retrieves an overview of the top-level symbols within a specified file
* `remove_project`: Removes a project from the Serena configuration.
* `replace_lines`: Replaces a range of lines within a file with new content.
* `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
* `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
* `switch_modes`: Activates modes by providing a list of their names
+4
View File
@@ -0,0 +1,4 @@
/_toc.yml
/jupyter_execute
/conf.py
/_build
+14
View File
@@ -0,0 +1,14 @@
# About Serena
* Serena is a powerful **coding agent toolkit** capable of turning an LLM into a fully-featured agent that works
**directly on your codebase**.
Unlike most other tools, it is not tied to an LLM, framework or an interface, making it easy to use it in a variety of ways.
* Serena provides essential **semantic code retrieval and editing tools** that are akin to an IDE's capabilities,
extracting code entities at the symbol level and exploiting relational structure.
When combined with an existing coding agent, these tools greatly enhance (token) efficiency.
* Serena is **free & open-source**, enhancing the capabilities of LLMs you already have access to free of charge.
Therefore, you can think of Serena as providing IDE-like tools to your LLM/coding agent.
With it, the agent no longer needs to read entire files, perform grep-like searches or string replacements to find and
edit the right code.
Instead, it can use code-centred tools like `find_symbol`, `find_referencing_symbols` and `insert_after_symbol`.
+17
View File
@@ -0,0 +1,17 @@
# LLM Integration
Serena provides the necessary [tools](035_tools) for coding workflows, but an LLM is required to do the actual work,
orchestrating tool use.
In general, Serena can be integrated with an LLM in several ways:
* by using the **model context protocol (MCP)**.
Serena provides an MCP server which integrates with
* Claude Code and Claude Desktop,
* Terminal-based clients like Codex, Gemini-CLI, Qwen3-Coder, rovodev, OpenHands CLI and others,
* IDEs like VSCode, Cursor or IntelliJ,
* Extensions like Cline or Roo Code
* Local clients like [OpenWebUI](https://docs.openwebui.com/openapi-servers/mcp), [Jan](https://jan.ai/docs/mcp-examples/browser/browserbase#enable-mcp), [Agno](https://docs.agno.com/introduction/playground) and others
* by using [mcpo to connect it to ChatGPT](../03-special-guides/serena_on_chatgpt.md) or other clients that don't support MCP but do support tool calling via OpenAPI.
* by incorporating Serena's tools into an agent framework of your choice, as illustrated [here](../03-special-guides/custom_agent).
Serena's tool implementation is decoupled from the framework-specific code and can thus easily be adapted to any agent framework.
@@ -0,0 +1,63 @@
# Language Support
Serena's semantic code analysis capabilities build on **language servers** using the widely implemented
language server protocol (LSP). The LSP provides a set of versatile code querying
and editing functionalities based on symbolic understanding of the code.
Equipped with these capabilities, Serena discovers and edits code just like a seasoned developer
making use of an IDE's capabilities would.
Serena can efficiently find the right context and do the right thing even in very large and
complex projects! So not only is it free and open-source, it frequently achieves better results
than existing solutions that charge a premium.
Language servers provide support for a wide range of programming languages.
With Serena, we provide direct, out-of-the-box support for:
* **AL**
* **Bash**
* **C#**
* **C/C++**
(you may experience issues with finding references, we are working on it)
* **Clojure**
* **Dart**
* **Elixir**
(requires installation of NextLS and Elixir; Windows not supported)
* **Elm**
(requires Elm compiler)
* **Erlang**
(requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang)
* **Fortran**
(requires installation of fortls: `pip install fortls`)
* **Go**
(requires installation of `gopls`)
* **Haskell**
(automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects)
* **Java**
* **JavaScript**
* **Julia**
* **Kotlin**
(uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear)
* **Lua**
* **Markdown**
(must be explicitly specified via `--language markdown` when generating project config, primarily useful for documentation-heavy projects)
* **Nix**
(requires nixd installation)
* **Perl**
(requires installation of Perl::LanguageServer)
* **PHP**
(uses Intelephense LSP; set `INTELEPHENSE_LICENSE_KEY` environment variable for premium features)
* **Python**
* **R**
(requires installation of the `languageserver` R package)
* **Ruby**
(by default, uses [ruby-lsp](https://github.com/Shopify/ruby-lsp), specify ruby_solargraph as your language to use the previous solargraph based implementation)
* **Rust**
(requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain)
* **Scala**
(requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP)
* **Swift**
* **TypeScript**
* **Zig**
(requires installation of ZLS - Zig Language Server)
Support for further languages can easily be added by providing a shallow adapter for a new language server implementation,
see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena/memories/adding_new_language_support_guide.md).
+24
View File
@@ -0,0 +1,24 @@
# Serena in Action
## Demonstration 1: Efficient Operation in Claude Code
A demonstration of Serena efficiently retrieving and editing code within Claude Code, thereby saving tokens and time. Efficient operations are not only useful for saving costs, but also for generally improving the generated code's quality. This effect may be less pronounced in very small projects, but often becomes of crucial importance in larger ones.
<video src="https://github.com/user-attachments/assets/ab78ebe0-f77d-43cc-879a-cc399efefd87"
controls
preload="metadata"
style="max-width: 100%; height: auto;">
Your browser does not support the video tag.
</video>
## Demonstration 2: Serena in Claude Desktop
A demonstration of Serena implementing a small feature for itself (a better log GUI) with Claude Desktop.
Note how Serena's tools enable Claude to find and edit the right symbols.
<video src="https://github.com/user-attachments/assets/6eaa9aa1-610d-4723-a2d6-bf1e487ba753"
controls
preload="metadata"
style="max-width: 100%; height: auto;">
Your browser does not support the video tag.
</video>
+47
View File
@@ -0,0 +1,47 @@
# List of Tools
Find the full list of Serena's tools below (output of `<serena> tools list --all`).
Note that in most configurations, only a subset of these tools will be enabled simultaneously (see the section on [configuration](../02-usage/050_configuration) for details).
* `activate_project`: Activates a project based on the project name or path.
* `check_onboarding_performed`: Checks whether project onboarding was already performed.
* `create_text_file`: Creates/overwrites a file in the project directory.
* `delete_lines`: Deletes a range of lines within a file.
* `delete_memory`: Deletes a memory from Serena's project-specific memory store.
* `execute_shell_command`: Executes a shell command.
* `find_file`: Finds files in the given relative paths
* `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
* `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filter
ed by type).
* `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools,
contexts, and modes.
* `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
* `initial_instructions`: Provides instructions on how to use the Serena toolbox.
* `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
* `insert_at_line`: Inserts content at a given line in a file.
* `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
* `jet_brains_find_referencing_symbols`: Finds symbols that reference the given symbol
* `jet_brains_find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (option
ally filtered by type).
* `jet_brains_get_symbols_overview`: Retrieves an overview of the top-level symbols within a specified file
* `list_dir`: Lists files and directories in the given directory (optionally with recursion).
* `list_memories`: Lists memories in Serena's project-specific memory store.
* `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
* `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with th
e necessary context).
* `read_file`: Reads a file within the project directory.
* `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
* `remove_project`: Removes a project from the Serena configuration.
* `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
* `replace_lines`: Replaces a range of lines within a file with new content.
* `replace_content`: Replaces content in a file (optionally using regular expressions).
* `replace_symbol_body`: Replaces the full definition of a symbol.
* `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
* `search_for_pattern`: Performs a search for a pattern in the project.
* `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
* `switch_modes`: Activates modes by providing a list of their names
* `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
* `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
* `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
* `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
@@ -0,0 +1,51 @@
# Comparison with Other Coding Agents
To our knowledge, Serena is the first fully-featured coding agent where the
entire functionality is made available through an MCP server,
thus not requiring additional API keys or subscriptions if access to an LLM
is already available through an MCP-compatible client.
## Subscription-Based Coding Agents
Many prominent subscription-based coding agents are parts of IDEs like
Windsurf, Cursor and VSCode.
Serena's functionality is similar to Cursor's Agent, Windsurf's Cascade or
VSCode's agent mode.
Serena has the advantage of not requiring a subscription.
More technical differences are:
* Serena navigates and edits code using a language server, so it has a symbolic
understanding of the code.
IDE-based tools often use a text search-based or purely text file-based approach, which is often
less powerful, especially for large codebases.
* Serena is not bound to a specific interface (IDE or CLI).
Serena's MCP server can be used with any MCP client (including some IDEs).
* Serena is not bound to a specific large language model or API.
* Serena is open-source and has a small codebase, so it can be easily extended
and modified.
## API-Based Coding Agents
An alternative to subscription-based agents are API-based agents like Claude
Code, Cline, Aider, Roo Code and others, where the usage costs map directly
to the API costs of the underlying LLM.
Some of them (like Cline) can even be included in IDEs as an extension.
They are often very powerful and their main downside are the (potentially very
high) API costs.
Serena itself can be used as an API-based agent (see the [section on Agno](../03-special-guides/custom_agent.md)).
The main difference between Serena and other API-based agents is that Serena can
also be used as an MCP server, thus not requiring
an API key and bypassing the API costs.
## Other MCP-Based Coding Agents
There are other MCP servers designed for coding, like [DesktopCommander](https://github.com/wonderwhy-er/DesktopCommanderMCP) and
[codemcp](https://github.com/ezyang/codemcp).
However, to the best of our knowledge, none of them provide semantic code
retrieval and editing tools; they rely purely on text-based analysis.
It is the integration of language servers and the MCP that makes Serena unique
and so powerful for challenging coding tasks, especially in the context of
larger codebases.
+34
View File
@@ -0,0 +1,34 @@
# Acknowledgements
## Sponsors
We are very grateful to our [sponsors](https://github.com/sponsors/oraios), who help us drive Serena's development.
The core team (the founders of [Oraios AI](https://oraios-ai.de/)) put in a lot of work in order to turn Serena into a useful open source project.
So far, there is no business model behind this project, and sponsors are our only source of income from it.
Sponsors help us dedicate more time to the project, managing contributions, and working on larger features (like better tooling based on more advanced
LSP features, VSCode integration, debugging via the DAP, and several others).
If you find this project useful to your work, or would like to accelerate the development of Serena, consider becoming a sponsor.
We are proud to announce that the Visual Studio Code team, together with Microsoft’s Open Source Programs Office and GitHub Open Source
have decided to sponsor Serena with a one-time contribution!
## Community Contributions
A significant part of Serena, especially support for various languages, was contributed by the open source community.
We are very grateful for the many contributors who made this possible and who played an important role in making Serena
what it is today.
## Technologies
We built Serena on top of multiple existing open-source technologies, the most important ones being:
1. [multilspy](https://github.com/microsoft/multilspy).
A library which wraps language server implementations and adapts them for interaction via Python
and which provided the basis for our library Solid-LSP (src/solidlsp).
Solid-LSP provides pure synchronous LSP calls and extends the original library with the symbolic logic
that Serena required.
2. [Python MCP SDK](https://github.com/modelcontextprotocol/python-sdk)
3. All the language servers that we use through Solid-LSP.
Without these projects, Serena would not have been possible (or would have been significantly more difficult to build).
+6
View File
@@ -0,0 +1,6 @@
# Usage
Serena can be used in various ways and supports coding workflows through a project-based approach.
Its configuration is flexible and allows tailoring it to your specific needs.
In this section, you will find general usage instructions as well as concrete instructions for selected integrations.
+12
View File
@@ -0,0 +1,12 @@
# Prerequisites
## Package Manager: uv
Serena is managed by `uv`.
If you do not have it yet, install it following the instructions [here](https://docs.astral.sh/uv/getting-started/installation/).
## Language-Specific Requirements
Depending on the programming language you intend to use with Serena, you may need to install additional tools or SDKs if you
intend to use the language server backend of Serena.
See the [language support documentation](../01-about/020_programming-languages) for details.
+226
View File
@@ -0,0 +1,226 @@
# Running Serena
Serena is a command-line tool with a variety of sub-commands.
This section describes
* varies ways of running Serena
* how to run and configure the most important command, i.e. starting the MCP server
* other useful commands.
## Ways of Running Serena
In the following, we will refer to the command used to run Serena as `<serena>`,
which you should replace with the appropriate command based on your chosen method.
### Using uvx
`uvx` is part of `uv`. It can be used to run the latest version of Serena directly from the repository, without an explicit local installation.
uvx --from git+https://github.com/oraios/serena serena
Explore the CLI to see some of the customization options that serena provides (more info on them below).
### Local Installation
1. Clone the repository and change into it.
```shell
git clone https://github.com/oraios/serena
cd serena
```
2. Run Serena via
```shell
uv run serena
```
when within the serena installation directory.
From other directories, run it with the `--directory` option, i.e.
```shell
uv run --directory /abs/path/to/serena serena
```
:::{note}
Adding the `--directory` option results in the working directory being set to the Serena directory.
As a consequence, you will need to specify paths when using CLI commands that would otherwise operate on the current directory.
:::
(docker)=
### Using Docker (Experimental)
:::{warning}
Docker support is currently experimental with several limitations.
:::
You can run the Serena MCP server directly via docker as follows,
assuming that the projects you want to work on are all located in `/path/to/your/projects`:
```shell
docker run --rm -i --network host -v /path/to/your/projects:/workspaces/projects ghcr.io/oraios/serena:latest serena
```
Replace `/path/to/your/projects` with the absolute path to your projects directory. The Docker approach provides:
* Better security isolation for shell command execution
* No need to install language servers and dependencies locally
* Consistent environment across different systems
Alternatively, use docker compose with the `compose.yml` file provided in the repository.
See our [Docker Setup](https://github.com/oraios/serena/blob/main/DOCKER.md) documentation for more detailed setup instructions, configuration options, and known limitations.
### Using Nix
If you are using Nix and [have enabled the `nix-command` and `flakes` features](https://nixos.wiki/wiki/flakes), you can run Serena using the following command:
```bash
nix run github:oraios/serena -- <command> [options]
```
You can also install Serena by referencing this repo (`github:oraios/serena`) and using it in your Nix flake. The package is exported as `serena`.
## Running the MCP Server
Given your preferred method of running Serena, you can start the MCP server using the `start-mcp-server` command:
<serena> start-mcp-server [options]
Note that no matter how you run the MCP server, Serena will, by default, start a web-based dashboard on localhost that will allow you to inspect
the server's operations, logs, and configuration.
### Standard I/O Mode
The typical usage involves the client (e.g. Claude Code, Codex or Cursor) running
the MCP server as a subprocess and using the process' stdin/stdout streams to communicate with it.
In order to launch the server, the client thus needs to be provided with the command to run the MCP server.
:::{note}
MCP servers which use stdio as a protocol are somewhat unusual as far as client/server architectures go, as the server
necessarily has to be started by the client in order for communication to take place via the server's standard input/output streams.
In other words, you do not need to start the server yourself. The client application (e.g. Claude Desktop) takes care of this and
therefore needs to be configured with a launch command.
:::
Communication over stdio is the default for the Serena MCP server, so in the simplest
case, you can simply run the `start-mcp-server` command without any additional options.
<serena> start-mcp-server
For example, to run the server in stdio mode via `uvx`, you would run:
uvx --from git+https://github.com/oraios/serena serena start-mcp-server
See the section ["Configuring Your MCP Client"](030_clients) for specific information on how to configure your MCP client (e.g. Claude Code, Codex, Cursor, etc.)
to use such a launch command.
### Streamable HTTP Mode
When using instead the *Streamable HTTP* mode, you control the server lifecycle yourself,
i.e. you start the server and provide the client with the URL to connect to it.
Simply provide `start-mcp-server` with the `--transport streamable-http` option and optionally provide the desired port
via the `--port` option.
<serena> start-mcp-server --transport streamable-http --port <port>
For example, to run the Serena MCP server in streamable HTTP mode on port 9121 using uvx,
you would run
uvx --from git+https://github.com/oraios/serena serena start-mcp-server --transport streamable-http --port 9121
and then configure your client to connect to `http://localhost:9121/mcp`.
Note that while the legacy SSE transport is also supported (via `--transport sse` with corresponding /sse endpoint), its use is discouraged.
(mcp-args)=
### MCP Server Command-Line Arguments
The Serena MCP server supports a wide range of additional command-line options.
Use the command
<serena> start-mcp-server --help
to get a list of all available options.
Some useful options include:
* `--project <path|name>`: specify the project to work on by name or path.
* `--context <context>`: specify the operation [context](contexts) in which Serena shall operate
* `--mode <mode>`: specify one or more [modes](modes) to enable (can be passed several times)
* `--enable-web-dashboard <true|false>`: enable or disable the web dashboard (enabled by default)
## Other Commands
Serena provides several other commands in addition to `start-mcp-server`,
most of which are related to project setup and configuration.
To get a list of available commands, run:
<serena> --help
To get help on a specific command, run:
<serena> <command> --help
In general, add `--help` to any command or sub-command to get information about its usage and available options.
Here are some examples of commands you might find useful:
```bash
# get help about a sub-command
<serena> tools list --help
# list all available tools
<serena> tools list --all
# get detailed description of a specific tool
<serena> tools description find_symbol
# creating a new Serena project in the current directory
<serena> project create
# creating and immediately indexing a project
<serena> project create --index
# indexing the project in the current directory (auto-creates if needed)
<serena> project index
# run a health check on the project in the current directory
<serena> project health-check
# check if a path is ignored by the project
<serena> project is_ignored_path path/to/check
# edit Serena's configuration file
<serena> config edit
# list available contexts
<serena> context list
# create a new context
<serena> context create my-custom-context
# edit a custom context
<serena> context edit my-custom-context
# list available modes
<serena> mode list
# create a new mode
<serena> mode create my-custom-mode
# edit a custom mode
<serena> mode edit my-custom-mode
# list available prompt definitions
<serena> prompts list
# create an override for internal prompts
<serena> prompts create-override prompt-name
# edit a prompt override
<serena> prompts edit-override prompt-name
```
Explore the full set of commands and options using the CLI itself!
+160
View File
@@ -0,0 +1,160 @@
# Configuring Your MCP Client
In the following, we provide default configurations for popular MCP-enabled clients.
Depending on your needs, you might want to customize Serena's behaviour by
* [adding command-line arguments](mcp-args)
* [adjusting configuration](050_configuration).
## Claude Code
Serena is a great way to make Claude Code both cheaper and more powerful!
From your project directory, add serena with a command like this,
```shell
claude mcp add serena -- <serena> start-mcp-server --context ide-assistant --project "$(pwd)"
```
where `<serena>` is [your way of running Serena](020_running).
For example, when using `uvx`, the above command becomes
```shell
claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena start-mcp-server --context ide-assistant --project "$(pwd)"
```
Note:
* We use the `ide-assistant` context to disable unnecessary tools (avoiding duplication
with Claude Code's built-in capabilities).
* We specify the current directory as the project directory with `--project "$(pwd)"`, such
that Serena is configured to work on the current project from the get-go, following
Claude Code's mode of operation.
Be sure to use at least `v1.0.52` of Claude Code (as earlier versions do not read MCP server system prompts upon startup).
## Codex
Serena works with OpenAI's Codex CLI out of the box, but you have to use the `codex` context for it to work properly. (The technical reason is that Codex doesn't fully support the MCP specifications, so some massaging of tools is required.).
Add a [run command](020_running) to `~/.codex/config.toml` to configure Serena for all Codex sessions;
create the file if it does not exist.
For example, when using `uvx`, add the following section:
```toml
[mcp_servers.serena]
command = "uvx"
args = ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server", "--context", "codex"]
```
After codex has started, you need to activate the project, which you can do by saying:
"Activate the current dir as project using serena"
> If you don't activate the project, you will not be able to use Serena's tools!
That's it! Have a look at `~/.codex/log/codex-tui.log` to see if any errors occurred.
Serena's dashboard will run if you have not disabled it in the configuration, but due to Codex's sandboxing, the web browser
may not open automatically. You can open it manually by going to `http://localhost:24282/dashboard/index.html` (or a higher port, if
that was already taken).
> Codex will often show the tools as `failed` even though they are successfully executed. This is not a problem, seems to be a bug in Codex. Despite the error message, everything works as expected.
## Claude Desktop
On Windows and macOS there are official [Claude Desktop applications by Anthropic](https://claude.ai/download), for Linux there is an [open-source
community version](https://github.com/aaddrick/claude-desktop-debian).
To configure MCP server settings, go to File / Settings / Developer / MCP Servers / Edit Config,
which will let you open the JSON file `claude_desktop_config.json`.
Add the `serena` MCP server configuration, using a [run command](020_running.md) depending on your setup.
* local installation:
```json
{
"mcpServers": {
"serena": {
"command": "/abs/path/to/uv",
"args": ["run", "--directory", "/abs/path/to/serena", "serena", "start-mcp-server"]
}
}
}
```
* uvx:
```json
{
"mcpServers": {
"serena": {
"command": "/abs/path/to/uvx",
"args": ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"]
}
}
}
```
* docker:
```json
{
"mcpServers": {
"serena": {
"command": "docker",
"args": ["run", "--rm", "-i", "--network", "host", "-v", "/path/to/your/projects:/workspaces/projects", "ghcr.io/oraios/serena:latest", "serena", "start-mcp-server", "--transport", "stdio"]
}
}
}
```
If you are using paths containing backslashes for paths on Windows
(note that you can also just use forward slashes), be sure to escape them correctly (`\\`).
That's it! Save the config and then restart Claude Desktop. You are ready for activating your first project.
:::{attention}
Be sure to fully quit the Claude Desktop application via File / Exit, as regularly closing the application will just minimize it.
:::
After restarting, you should see Serena's tools in your chat interface (notice the small hammer icon).
For more information on MCP servers with Claude Desktop, see [the official quick start guide](https://modelcontextprotocol.io/quickstart/user).
## Other Clients
### Terminal-Based Clients
There are many terminal-based coding assistants that support MCP servers, such as
* [Gemini-CLI](https://github.com/google-gemini/gemini-cli),
* [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder),
* [rovodev](https://community.atlassian.com/forums/Rovo-for-Software-Teams-Beta/Introducing-Rovo-Dev-CLI-AI-Powered-Development-in-your-terminal/ba-p/3043623),
* [OpenHands CLI](https://docs.all-hands.dev/usage/how-to/cli-mode) and
* [opencode](https://github.com/sst/opencode).
They generally benefit from the symbolic tools provided by Serena. You might want to customize some aspects of Serena
by writing your own context, modes or prompts to adjust it to the client's respective internal capabilities (and your general workflow).
### MCP-Enabled IDEs and Coding Clients (Cline, Roo-Code, Cursor, Windsurf, etc.)
Being an MCP Server, Serena can be included in any MCP Client. Most of the popular
existing coding assistants (e.g. IDE extensions) and AI-enabled IDEs themselves support connections
to MCP Servers. Serena generally boosts performance by providing efficient tools for symbolic operations.
We generally **recommend to use the `ide-assistant` context** for these integrations by adding the arguments `--context ide-assistant`
in order to reduce tool duplication.
### Local GUIs and Agent Frameworks
Over the last months, several technologies have emerged that allow you to run a local GUI client
and connect it to an MCP server. The respective applications will typically work with Serena out of the box.
Some of the leading open source GUI applications are
* [Jan](https://jan.ai/docs/mcp),
* [OpenHands](https://github.com/All-Hands-AI/OpenHands/),
* [OpenWebUI](https://docs.openwebui.com/openapi-servers/mcp) and
* [Agno](https://docs.agno.com/introduction/playground).
These applications allow to combine Serena with almost any LLM (including locally running ones)
and offer various other integrations.
+138
View File
@@ -0,0 +1,138 @@
# The Project Workflow
Serena uses a project-based workflow.
A **project** is simply a directory on your filesystem that contains code and other files
that you want Serena to work with.
Assuming that you have project you want to work with (which may initially be empty),
setting up a project with Serena typically involves the following steps:
1. **Project creation**: Configuring project settings for Serena (and indexing the project, if desired)
2. **Project activation**: Making Serena aware of the project you want to work with
3. **Onboarding**: Getting Serena familiar with the project (creating memories)
4. **Working on coding tasks**: Using Serena to help you with actual coding tasks in the project
(project-creation-indexing)=
## Project Creation & Indexing
You can create a project either
* implicitly, by just activating a directory as a project while already in a conversation; this will use default settings for your project (skip to the next section).
* explicitly, using the project creation command, or
### Explicit Project Creation
To explicitly create a project, use the following command while in the project directory:
<serena> project create [options]
For instance, when using `uvx`, run
uvx --from git+https://github.com/oraios/serena serena project create [options]
* For an empty project, you will need to specify the programming language
(e.g., `--language python`).
* For an existing project, the main programming language will be detected automatically,
but you can choose to explicitly specify multiple languages by passing the `--language` parameter
multiple times (e.g. `--language python --language typescript`).
* You can optionally specify a custom project name with `--name "My Project"`.
* You can immediately index the project after creation with `--index`.
After creation, you can adjust the project settings in the generated `.serena/project.yml` file.
(indexing)=
### Indexing
Especially for larger project, it is advisable to index the project after creation (in order to avoid
delays during MCP server startup or the first tool application):
While in the project directory, run this command:
<serena> project index
Indexing has to be called only once. During regular usage, Serena will automatically update the index whenever files change.
## Project Activation
Project activation makes Serena aware of the project you want to work with.
You can either choose to do this
* while in a conversation, by telling the model to activate a project, e.g.,
* "Activate the project /path/to/my_project" (for first-time activation with auto-creation)
* "Activate the project my_project"
Note that this option requires the `activate_project` tool to be active,
which it isn't in the (default version) of context `ide-assistant` if a project is provided at startup.
(The tool is deactivated because we assume that in the ide-assistant context the user will only work on the open project and have
no need to switch it.)
* when the MCP server starts, by passing the project path or name as a command-line argument
(e.g. when working on a fixed project in `ide-assistant` mode): `--project <path|name>`
## Onboarding & Memories
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.
**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.
## Preparing Your Project
When using Serena to work on your project, it can be helpful to follow a few best practices.
### Structure Your Codebase
Serena uses the code structure for finding, reading and editing code. This means that it will
work well with well-structured code but may perform poorly on fully unstructured one (like a "God class"
with enormous, non-modular functions).
Furthermore, for languages that are not statically typed, the use of type annotations (if supported)
are highly beneficial.
### Start from a Clean State
It is best to start a code generation task from a clean git state. Not only will
this make it easier for you to inspect the changes, but also the model itself will
have a chance of seeing what it has changed by calling `git diff` and thereby
correct itself or continue working in a followup conversation if needed.
### Use Platform-Native Line Endings
**Important**: since Serena will write to files using the system-native line endings
and it might want to look at the git diff, it is important to
set `git config core.autocrlf` to `true` on Windows.
With `git config core.autocrlf` set to `false` on Windows, you may end up with huge diffs
due to line endings only.
It is generally a good idea to globally enable this git setting on Windows:
```shell
git config --global core.autocrlf true
```
### Logging, Linting, and Automated Tests
Serena can successfully complete tasks in an _agent loop_, where it iteratively
acquires information, performs actions, and reflects on the results.
However, Serena cannot use a debugger; it must rely on the results of program executions,
linting results, and test results to assess the correctness of its actions.
Therefore, software that is designed to meaningful interpretable outputs (e.g. log messages)
and that has a good test coverage is much easier to work with for Serena.
We generally recommend to start an editing task from a state where all linting checks and tests pass.
+96
View File
@@ -0,0 +1,96 @@
# Configuration
Serena is very flexible in terms of configuration. While for most users, the default configurations will work,
you can fully adjust it to your needs by editing a few yaml files. You can disable tools, change Serena's instructions
(what we denote as the `system_prompt`), adjust the output of tools that just provide a prompt, and even adjust tool descriptions.
Serena is configured in four places:
1. The `serena_config.yml` for general settings that apply to all clients and projects.
It is located in your user directory under `.serena/serena_config.yml`.
If you do not explicitly create the file, it will be auto-generated when you first run Serena.
You can edit it directly or use
```shell
uvx --from git+https://github.com/oraios/serena serena config edit
```
(or use the `--directory` command version).
2. In the arguments passed to the `start-mcp-server` in your client's config (see below),
which will apply to all sessions started by the respective client. In particular, the [context](contexts) parameter
should be set appropriately for Serena to be best adjusted to existing tools and capabilities of your client.
See for a detailed explanation. You can override all entries from the `serena_config.yml` through command line arguments.
3. In the `.serena/project.yml` file within your project. This will hold project-level configuration that is used whenever
that project is activated. This file will be autogenerated when you first use Serena on that project, but you can also
create it explicitly with `serena project create [options]` (see the [](project-creation-indexing)
for details on available options).
4. Through the context and modes (see below).
After the initial setup, continue with one of the sections below, depending on how you
want to use Serena.
## Modes and Contexts
Serena's behavior and toolset can be adjusted using contexts and modes.
These allow for a high degree of customization to best suit your workflow and the environment Serena is operating in.
(contexts)=
### Contexts
A **context** defines the general environment in which Serena is operating.
It influences the initial system prompt and the set of available tools.
A context is set at startup when launching Serena (e.g., via CLI options for an MCP server or in the agent script) and cannot be changed during an active session.
Serena comes with pre-defined contexts:
* `desktop-app`: Tailored for use with desktop applications like Claude Desktop. This is the default.
* `agent`: Designed for scenarios where Serena acts as a more autonomous agent, for example, when used with Agno.
* `ide-assistant`: Optimized for integration into IDEs like VSCode, Cursor, or Cline, focusing on in-editor coding assistance.
Choose the context that best matches the type of integration you are using.
When launching Serena, specify the context using `--context <context-name>`.
Note that for cases where parameter lists are specified (e.g. Claude Desktop), you must add two parameters to the list.
If you are using a local server (such as Llama.cpp) which requires you to use OpenAI-compatible tool descriptions, use context `oaicompat-agent` instead of `agent`.
You can manage contexts using the `context` command,
<serena> context --help
<serena> context list
<serena> context create <context-name>
<serena> context edit <context-name>
<serena> context delete <context-name>
where `<serena>` is [your way of running Serena](020_running).
(modes)=
### Modes
Modes further refine Serena's behavior for specific types of tasks or interaction styles. Multiple modes can be active simultaneously, allowing you to combine their effects. Modes influence the system prompt and can also alter the set of available tools by excluding certain ones.
Examples of built-in modes include:
* `planning`: Focuses Serena on planning and analysis tasks.
* `editing`: Optimizes Serena for direct code modification tasks.
* `interactive`: Suitable for a conversational, back-and-forth interaction style.
* `one-shot`: Configures Serena for tasks that should be completed in a single response, often used with `planning` for generating reports or initial plans.
* `no-onboarding`: Skips the initial onboarding process if it's not needed for a particular session.
* `onboarding`: (Usually triggered automatically) Focuses on the project onboarding process.
Modes can be set at startup (similar to contexts) but can also be _switched dynamically_ during a session.
You can instruct the LLM to use the `switch_modes` tool to activate a different set of modes (e.g., "Switch to planning and one-shot modes").
When launching Serena, specify modes using `--mode <mode-name>`; multiple modes can be specified, e.g. `--mode planning --mode no-onboarding`.
:warning: **Mode Compatibility**: While you can combine modes, some may be semantically incompatible (e.g., `interactive` and `one-shot`).
Serena currently does not prevent incompatible combinations; it is up to the user to choose sensible mode configurations.
You can manage contexts using the `mode` command,
<serena> mode --help
<serena> mode list
<serena> mode create <mode-name>
<serena> mode edit <mode-name>
<serena> mode delete <mode-name>
where `<serena>` is [your way of running Serena](020_running).
+22
View File
@@ -0,0 +1,22 @@
# The Dashboard and GUI Tool
Serena comes with built-in tools for monitoring and managing the current session:
* the **web-based dashboard** (enabled by default)
The dashboard provides detailed information on your Serena session, the current configuration and provides access to logs.
Some settings (e.g. the current set of active programming languages) can also be directly modified through the dashboard.
The dashboard is supported on all platforms.
By default, it will be accessible at `http://localhost:24282/dashboard/index.html`,
but a higher port may be used if the default port is unavailable/multiple instances are running.
* the **GUI tool** (disabled by default)
The GUI tool is a native application window which displays logs.
This is mainly supported on Windows, but it may also work on Linux; macOS is unsupported.
Both can be configured in Serena's [configuration](050_configuration) file (`serena_config.yml`).
If enabled, they will automatically be opened as soon as the Serena agent/MCP server is started.
+12
View File
@@ -0,0 +1,12 @@
# Security Considerations
As fundamental abilities for a coding agent, Serena contains tools for executing shell commands and modifying files.
Therefore, if the respective tool calls are not monitored or restricted (and execution takes place in a sensitive environment),
there is a risk of unintended consequences.
Therefore, to reduce the risk of unintended consequences when using Serena, it is recommended to
* back up your work regularly (e.g. use a version control system like Git),
* monitor tool executions carefully (e.g. via your MCP client, provided that it supports it),
* consider enabling read-only mode for your project (set `read_only: True` in project.yml) if you only want to analyze code without modifying it,
* restrict the set of allowed tools via the [configuration](050_configuration),
* use a sandboxed environment for running Serena (e.g. by [using Docker](docker)).
+32
View File
@@ -0,0 +1,32 @@
# Additional Usage Pointers
## Prompting Strategies
We found that it is often a good idea to spend some time conceptualizing and planning a task
before actually implementing it, especially for non-trivial task. This helps both in achieving
better results and in increasing the feeling of control and staying in the loop. You can
make a detailed plan in one session, where Serena may read a lot of your code to build up the context,
and then continue with the implementation in another (potentially after creating suitable memories).
## Running Out of Context
For long and complicated tasks, or tasks where Serena has read a lot of content, you
may come close to the limits of context tokens. In that case, it is often a good idea to continue
in a new conversation. Serena has a dedicated tool to create a summary of the current state
of the progress and all relevant info for continuing it. You can request to create this summary and
write it to a memory. Then, in a new conversation, you can just ask Serena to read the memory and
continue with the task. In our experience, this worked really well. On the up-side, since in a
single session there is no summarization involved, Serena does not usually get lost (unlike some
other agents that summarize under the hood), and it is also instructed to occasionally check whether
it's on the right track.
Serena instructs the LLM to be economical in general, so the problem of running out of context
should not occur too often, unless the task is very large or complicated.
## Serena and Git Worktrees
[git-worktree](https://git-scm.com/docs/git-worktree) can be an excellent way to parallelize your work. More on this in [Anthropic: Run parallel Claude Code sessions with Git worktrees](https://docs.claude.com/en/docs/claude-code/common-workflows#run-parallel-claude-code-sessions-with-git-worktrees).
When it comes to serena AND git-worktree AND larger projects (that take longer to index),
the recommended way is to COPY your `$ORIG_PROJECT/.serena/cache` to `$GIT_WORKTREE/.serena/cache`.
Perform [pre-indexing of your project](indexing) to avoid having to re-index per each worktree you create.
+3
View File
@@ -0,0 +1,3 @@
# Special Guides
This section contains special guides for certain topics that require more in-depth explanations.
@@ -63,5 +63,5 @@ configuration file (`.yml`).
It should be straightforward to incorporate Serena into any
agent framework (like [pydantic-ai](https://ai.pydantic.dev/), [langgraph](https://langchain-ai.github.io/langgraph/tutorials/introduction/) or others).
Typically, you need only to write an adapter for Serena's tools to the tool representation in the framework of your choice,
as was done by us for Agno with [SerenaAgnoToolkit](/src/serena/agno.py).
as was done by us for Agno with `SerenaAgnoToolkit` (see `/src/serena/agno.py`).
File renamed without changes.
+154
View File
@@ -0,0 +1,154 @@
# Book settings
# Learn more at https://jupyterbook.org/customize/config.html
#######################################################################################
# A default configuration that will be loaded for all jupyter books
# Users are expected to override these values in their own `_config.yml` file.
# This is also the "master list" of all allowed keys and values.
#######################################################################################
# Book settings
title : Serena Documentation # The title of the book. Will be placed in the left navbar.
author : Oraios AI & Oraios Software # The author of the book
copyright : "2025 by Serena contributors" # Copyright year to be placed in the footer
# Patterns to skip when building the book. Can be glob-style (e.g. "*skip.ipynb")
exclude_patterns : ['**.ipynb_checkpoints', '.DS_Store', 'Thumbs.db', '_build', 'jupyter_execute', '.jupyter_cache', '.pytest_cache', 'docs/autogen_rst.py', 'docs/create_toc.py']
# Auto-exclude files not in the toc
only_build_toc_files : true
#######################################################################################
# Execution settings
execute:
# NOTE: Notebooks are not executed, because test_notebooks.py executes them and stores them with outputs in the docs/ folder
# NOTE: If changed, repeat below in `nb_execution_mode`.
execute_notebooks : "off" # Whether to execute notebooks at build time. Must be one of ("auto", "force", "cache", "off")
cache : "" # A path to the jupyter cache that will be used to store execution artifacts. Defaults to `_build/.jupyter_cache/`
exclude_patterns : [] # A list of patterns to *skip* in execution (e.g. a notebook that takes a really long time)
timeout : 1000 # The maximum time (in seconds) each notebook cell is allowed to run.
run_in_temp : false # If `True`, then a temporary directory will be created and used as the command working directory (cwd),
# otherwise the notebook's parent directory will be the cwd.
allow_errors : true # If `False`, when a code cell raises an error the execution is stopped, otherwise all cells are always run.
stderr_output : show # One of 'show', 'remove', 'remove-warn', 'warn', 'error', 'severe'
#######################################################################################
# Parse and render settings
parse:
myst_enable_extensions: # default extensions to enable in the myst parser. See https://myst-parser.readthedocs.io/en/latest/using/syntax-optional.html
- amsmath
- colon_fence
# - deflist
- dollarmath
# - html_admonition
# - html_image
- linkify
# - replacements
# - smartquotes
- substitution
- tasklist
- html_admonition
- html_image
myst_url_schemes: [ mailto, http, https ] # URI schemes that will be recognised as external URLs in Markdown links
myst_dmath_double_inline: true # Allow display math ($$) within an inline context
#######################################################################################
# HTML-specific settings
html:
#favicon : # A path to a favicon image
use_edit_page_button : false # Whether to add an "edit this page" button to pages. If `true`, repository information in repository: must be filled in
use_repository_button : false # Whether to add a link to your repository button
use_issues_button : false # Whether to add an "open an issue" button
use_multitoc_numbering : true # Continuous numbering across parts/chapters
use_darkmode_button : false
extra_footer : ""
home_page_in_navbar : true # Whether to include your home page in the left Navigation Bar
baseurl : "https://oraios.github.io/serena/"
comments:
hypothesis : false
utterances : false
announcement : "" # A banner announcement at the top of the site.
#######################################################################################
# LaTeX-specific settings
latex:
latex_engine : pdflatex # one of 'pdflatex', 'xelatex' (recommended for unicode), 'luatex', 'platex', 'uplatex'
use_jupyterbook_latex : true # use sphinx-jupyterbook-latex for pdf builds as default
targetname : book.tex
# Add a bibtex file so that we can create citations
#bibtex_bibfiles:
# - refs.bib
#######################################################################################
# Launch button settings
launch_buttons:
notebook_interface : classic # The interface interactive links will activate ["classic", "jupyterlab"]
binderhub_url : "" # The URL of the BinderHub (e.g., https://mybinder.org)
jupyterhub_url : "" # The URL of the JupyterHub (e.g., https://datahub.berkeley.edu)
thebe : false # Add a thebe button to pages (requires the repository to run on Binder)
colab_url : "https://colab.research.google.com"
repository:
url : https://github.com/oraios/serena # The URL to your book's repository
path_to_book : docs # A path to your book's folder, relative to the repository root.
branch : main # Which branch of the repository should be used when creating links
#######################################################################################
# Advanced and power-user settings
sphinx:
extra_extensions :
- sphinx.ext.autodoc
- sphinx.ext.viewcode
- sphinx_toolbox.more_autodoc.sourcelink
#- sphinxcontrib.spelling
local_extensions : # A list of local extensions to load by sphinx specified by "name: path" items
recursive_update : false # A boolean indicating whether to overwrite the Sphinx config (true) or recursively update (false)
config : # key-value pairs to directly over-ride the Sphinx configuration
master_doc: "01-about/000_intro.md"
html_theme_options:
logo:
image_light: ../resources/serena-logo.svg
image_dark: ../resources/serena-logo-dark-mode.svg
autodoc_typehints_format: "short"
autodoc_member_order: "bysource"
autoclass_content: "both"
autodoc_default_options:
show-inheritance: True
autodoc_show_sourcelink: True
add_module_names: False
github_username: oraios
github_repository: serena
nb_execution_mode: "off"
nb_merge_streams: True # This is important for cell outputs to appear as single blocks rather than one block per line
python_use_unqualified_type_names: True
nb_mime_priority_overrides: [
[ 'html', 'application/vnd.jupyter.widget-view+json', 10 ],
[ 'html', 'application/javascript', 20 ],
[ 'html', 'text/html', 30 ],
[ 'html', 'text/latex', 40 ],
[ 'html', 'image/svg+xml', 50 ],
[ 'html', 'image/png', 60 ],
[ 'html', 'image/jpeg', 70 ],
[ 'html', 'text/markdown', 80 ],
[ 'html', 'text/plain', 90 ],
[ 'spelling', 'application/vnd.jupyter.widget-view+json', 10 ],
[ 'spelling', 'application/javascript', 20 ],
[ 'spelling', 'text/html', 30 ],
[ 'spelling', 'text/latex', 40 ],
[ 'spelling', 'image/svg+xml', 50 ],
[ 'spelling', 'image/png', 60 ],
[ 'spelling', 'image/jpeg', 70 ],
[ 'spelling', 'text/markdown', 80 ],
[ 'spelling', 'text/plain', 90 ],
]
mathjax_path: https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js
mathjax3_config:
loader: { load: [ '[tex]/configmacros' ] }
tex:
packages: { '[+]': [ 'configmacros' ] }
macros:
vect: ["{\\mathbf{\\boldsymbol{#1}} }", 1]
E: "{\\mathbb{E}}"
P: "{\\mathbb{P}}"
R: "{\\mathbb{R}}"
abs: ["{\\left| #1 \\right|}", 1]
simpl: ["{\\Delta^{#1} }", 1]
amax: "{\\text{argmax}}"
+155
View File
@@ -0,0 +1,155 @@
import logging
import os
import shutil
from pathlib import Path
from typing import Optional, List
log = logging.getLogger(os.path.basename(__file__))
TOP_LEVEL_PACKAGE = "serena"
PROJECT_NAME = "Serena"
def module_template(module_qualname: str):
module_name = module_qualname.split(".")[-1]
title = module_name.replace("_", r"\_")
return f"""{title}
{"=" * len(title)}
.. automodule:: {module_qualname}
:members:
:undoc-members:
"""
def index_template(package_name: str, doc_references: Optional[List[str]] = None, text_prefix=""):
doc_references = doc_references or ""
if doc_references:
doc_references = "\n" + "\n".join(f"* :doc:`{ref}`" for ref in doc_references) + "\n"
dirname = package_name.split(".")[-1]
title = dirname.replace("_", r"\_")
if title == TOP_LEVEL_PACKAGE:
title = "API Reference"
return f"{title}\n{'=' * len(title)}" + text_prefix + doc_references
def write_to_file(content: str, path: str):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(content)
os.chmod(path, 0o666)
_SUBTITLE = (
f"\n Here is the autogenerated documentation of the {PROJECT_NAME} API. \n \n "
"The Table of Contents to the left has the same structure as the "
"repository's package code. The links at each page point to the submodules and subpackages. \n"
)
def make_rst(src_root, rst_root, clean=False, overwrite=False, package_prefix=""):
"""Creates/updates documentation in form of rst files for modules and packages.
Does not delete any existing rst files. Thus, rst files for packages or modules that have been removed or renamed
should be deleted by hand.
This method should be executed from the project's top-level directory
:param src_root: path to library base directory, typically "src/<library_name>"
:param rst_root: path to the root directory to which .rst files will be written
:param clean: whether to completely clean the target directory beforehand, removing any existing .rst files
:param overwrite: whether to overwrite existing rst files. This should be used with caution as it will delete
all manual changes to documentation files
:package_prefix: a prefix to prepend to each module (for the case where the src_root is not the base package),
which, if not empty, should end with a "."
:return:
"""
rst_root = os.path.abspath(rst_root)
if clean and os.path.isdir(rst_root):
shutil.rmtree(rst_root)
base_package_name = package_prefix + os.path.basename(src_root)
# TODO: reduce duplication with same logic for subpackages below
files_in_dir = os.listdir(src_root)
module_names = [f[:-3] for f in files_in_dir if f.endswith(".py") and not f.startswith("_")]
subdir_refs = [
f"{f}/index"
for f in files_in_dir
if os.path.isdir(os.path.join(src_root, f))
and not f.startswith("_")
and not f.startswith(".")
]
package_index_rst_path = os.path.join(
rst_root,
"index.rst",
)
log.info(f"Writing {package_index_rst_path}")
write_to_file(
index_template(
base_package_name,
doc_references=module_names + subdir_refs,
text_prefix=_SUBTITLE,
),
package_index_rst_path,
)
for root, dirnames, filenames in os.walk(src_root):
if os.path.basename(root).startswith("_"):
continue
base_package_relpath = os.path.relpath(root, start=src_root)
base_package_qualname = package_prefix + os.path.relpath(
root,
start=os.path.dirname(src_root),
).replace(os.path.sep, ".")
for dirname in dirnames:
if dirname.startswith("_"):
log.debug(f"Skipping {dirname}")
continue
files_in_dir = os.listdir(os.path.join(root, dirname))
module_names = [
f[:-3] for f in files_in_dir if f.endswith(".py") and not f.startswith("_")
]
subdir_refs = [
f"{f}/index"
for f in files_in_dir
if os.path.isdir(os.path.join(root, dirname, f)) and not f.startswith("_")
]
package_qualname = f"{base_package_qualname}.{dirname}"
package_index_rst_path = os.path.join(
rst_root,
base_package_relpath,
dirname,
"index.rst",
)
log.info(f"Writing {package_index_rst_path}")
write_to_file(
index_template(package_qualname, doc_references=module_names + subdir_refs),
package_index_rst_path,
)
for filename in filenames:
base_name, ext = os.path.splitext(filename)
if ext == ".py" and not filename.startswith("_"):
module_qualname = f"{base_package_qualname}.{filename[:-3]}"
module_rst_path = os.path.join(rst_root, base_package_relpath, f"{base_name}.rst")
if os.path.exists(module_rst_path) and not overwrite:
log.debug(f"{module_rst_path} already exists, skipping it")
log.info(f"Writing module documentation to {module_rst_path}")
write_to_file(module_template(module_qualname), module_rst_path)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
docs_root = Path(__file__).parent
enable_module_docs = False
if enable_module_docs:
make_rst(
docs_root / ".." / "src" / "serena",
docs_root / "serena",
clean=True,
)
+9
View File
@@ -0,0 +1,9 @@
import os
from pathlib import Path
# This script provides a platform-independent way of making the jupyter-book call (used in pyproject.toml)
folder = Path(__file__).parent
toc_file = folder / "_toc.yml"
cmd = f"jupyter-book toc from-project docs -e .rst -e .md -e .ipynb >{toc_file}"
print(cmd)
os.system(cmd)
+2
View File
@@ -0,0 +1,2 @@
<meta http-equiv="refresh" content="0; url=01-about/000_intro.html">
If you are not redirected automatically, [click here](01-about/000_intro.html).
+5 -1
View File
@@ -76,7 +76,11 @@
formatter = pkgs.alejandra;
packages = {
serena = pythonSet.mkVirtualEnv "serena" workspace.deps.default;
serena-env = pythonSet.mkVirtualEnv "serena-env" workspace.deps.default;
serena = pkgs.runCommand "serena" {} ''
mkdir -p $out/bin
ln -s ${packages.serena-env}/bin/serena $out/bin/serena
'';
default = packages.serena;
};
+21 -4
View File
@@ -16,6 +16,7 @@ classifiers = [
dependencies = [
"requests>=2.32.3,<3",
"pyright>=1.1.396,<2",
"fortls>=3.2.2",
"overrides>=7.7.0,<8",
"python-dotenv>=1.0.0, <2",
"mcp==1.12.3",
@@ -64,8 +65,15 @@ dev = [
"types-pyyaml>=6.0.12.20241230",
"syrupy>=4.9.1",
"types-requests>=2.32.4.20241230",
# docs
"sphinx>=7,<8",
"sphinx_rtd_theme>=0.5.1",
"sphinx-toolbox==3.7.0",
"jupyter-book>=1,<2",
"nbsphinx",
"pyinstrument",
]
agno = ["agno>=1.2.6", "sqlalchemy>=2.0.40"]
agno = ["agno>=2.2.1", "sqlalchemy>=2.0.40"]
google = ["google-genai>=1.8.0"]
[project.urls]
@@ -122,6 +130,14 @@ lint = ["_black_check", "_ruff_check"]
format = ["_ruff_format", "_black_format"]
_mypy = "mypy src/serena src/solidlsp"
type-check = ["_mypy"]
# docs
_autogen_rst = "python docs/autogen_rst.py"
_sphinx_build = "sphinx-build -b html docs docs/_build -W --keep-going"
_jb_generate_toc = "python docs/create_toc.py"
_jb_generate_config = "jupyter-book config sphinx docs/"
doc-clean = "rm -rf docs/_build docs/03_api"
doc-generate-files = ["_autogen_rst", "_jb_generate_toc", "_jb_generate_config"]
doc-build = ["doc-clean", "doc-generate-files", "_sphinx_build"]
[tool.ruff]
target-version = "py311"
@@ -210,7 +226,6 @@ ignore = [
"COM812",
"RET503",
"RET504",
"UP038",
"F403",
"F405",
"C401",
@@ -276,11 +291,13 @@ markers = [
"dart: language server running for Dart",
"erlang: language server running for Erlang",
"scala: language server running for Scala",
"scala: language server running for Scala",
"al: language server running for AL (Microsoft Dynamics 365 Business Central)",
"rego: language server running for Rego",
"markdown: language server running for Markdown",
"julia: Julia language server tests",
"fortran: language server running for Fortran",
"haskell: Haskell language server tests",
"yaml: language server running for YAML",
]
[tool.codespell]
@@ -288,4 +305,4 @@ markers = [
skip = '.git*,*.svg,*.lock,*.min.*'
check-hidden = true
# ignore-regex = ''
# ignore-words-list = ''
# ignore-words-list = ''
+16 -7
View File
@@ -1,7 +1,6 @@
from agno.models.anthropic.claude import Claude
from agno.models.google.gemini import Gemini
from agno.playground.playground import Playground
from agno.playground.serve import serve_playground_app
from agno.os import AgentOS
from sensai.util import logging
from sensai.util.helper import mark_used
@@ -9,16 +8,26 @@ from serena.agno import SerenaAgnoAgentProvider
mark_used(Gemini, Claude)
# initialize logging (Note: since this module is reimported by serve_playground_app and the logging configuration
# is extended by SerenaAgentProvider, we must handle this here conditionally)
# initialize logging
if __name__ == "__main__":
logging.configure(level=logging.INFO)
# Define the model to use (see Agno documentation for supported models; these are just examples)
# model = Claude(id="claude-3-7-sonnet-20250219")
model = Gemini(id="gemini-2.5-pro-exp-03-25")
model = Gemini(id="gemini-2.5-pro")
app = Playground(agents=[SerenaAgnoAgentProvider.get_agent(model)]).get_app()
# Create the Serena agent using the existing provider
serena_agent = SerenaAgnoAgentProvider.get_agent(model)
# Create AgentOS app with the Serena agent
agent_os = AgentOS(
description="Serena coding assistant powered by AgentOS",
id="serena-agentos",
agents=[serena_agent],
)
app = agent_os.get_app()
if __name__ == "__main__":
serve_playground_app("agno_agent:app", reload=False, log_config=None)
# Start the AgentOS server
agent_os.serve(app="agno_agent:app", reload=False)
+5 -4
View File
@@ -7,20 +7,21 @@ import json
from pprint import pprint
from serena.agent import SerenaAgent
from serena.config.serena_config import SerenaConfig
from serena.constants import REPO_ROOT
from serena.tools import FindFileTool, FindReferencingSymbolsTool, GetSymbolsOverviewTool, SearchForPatternTool
from serena.tools import FindFileTool, FindReferencingSymbolsTool, GetSymbolsOverviewTool, JetBrainsFindSymbolTool, SearchForPatternTool
if __name__ == "__main__":
agent = SerenaAgent(project=REPO_ROOT, serena_config=SerenaConfig(gui_log_window_enabled=False, web_dashboard=False))
agent = SerenaAgent(project=REPO_ROOT)
# apply a tool
find_symbol_tool = agent.get_tool(JetBrainsFindSymbolTool)
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
find_file_tool = agent.get_tool(FindFileTool)
search_pattern_tool = agent.get_tool(SearchForPatternTool)
overview_tool = agent.get_tool(GetSymbolsOverviewTool)
result = agent.execute_task(
lambda: overview_tool.apply("src/solidlsp/ls.py"),
lambda: find_symbol_tool.apply("displayBasicStats"),
)
pprint(json.loads(result))
# input("Press Enter to continue...")
+53
View File
@@ -0,0 +1,53 @@
import cProfile
from pathlib import Path
from typing import Literal
from sensai.util import logging
from sensai.util.logging import LogTime
from sensai.util.profiling import profiled
from serena.agent import SerenaAgent
from serena.config.serena_config import SerenaConfig
from serena.tools import FindSymbolTool
log = logging.getLogger(__name__)
if __name__ == "__main__":
logging.configure()
# The profiler to use:
# Use pyinstrument for hierarchical profiling output
# Use cProfile to determine which functions take the most time overall (and use snakeviz to visualize)
profiler: Literal["pyinstrument", "cprofile"] = "cprofile"
project_path = Path(__file__).parent.parent # Serena root
serena_config = SerenaConfig.from_config_file()
serena_config.log_level = logging.INFO
serena_config.jetbrains = False
serena_config.gui_log_window_enabled = False
serena_config.web_dashboard = False
agent = SerenaAgent(str(project_path), serena_config=serena_config)
# wait for language server to be ready
agent.execute_task(lambda: log.info("Language server is ready."))
def tool_call():
"""This is the function we want to profile."""
# NOTE: We use apply (not apply_ex) to run the tool call directly on the main thread
with LogTime("Tool call"):
result = agent.get_tool(FindSymbolTool).apply(name_path="DQN")
log.info("Tool result:\n%s", result)
if profiler == "pyinstrument":
@profiled(log_to_file=True)
def profiled_tool_call():
tool_call()
profiled_tool_call()
elif profiler == "cprofile":
cProfile.run("tool_call()", "tool_call.pstat")
+207 -87
View File
@@ -6,12 +6,10 @@ import multiprocessing
import os
import platform
import sys
import threading
import webbrowser
from collections.abc import Callable
from concurrent.futures import Future, ThreadPoolExecutor
from logging import Logger
from typing import TYPE_CHECKING, Any, Optional, TypeVar
from typing import TYPE_CHECKING, Optional, TypeVar
from sensai.util import logging
from sensai.util.logging import LogTime
@@ -19,15 +17,17 @@ from sensai.util.logging import LogTime
from interprompt.jinja_template import JinjaTemplate
from serena import serena_version
from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats
from serena.config.context_mode import RegisteredContext, SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition, ToolSet
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition
from serena.dashboard import SerenaDashboardAPI
from serena.ls_manager import LanguageServerManager
from serena.project import Project
from serena.prompt_factory import SerenaPromptFactory
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, Tool, ToolMarker, ToolRegistry
from serena.task_executor import TaskExecutor
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry
from serena.util.inspection import iter_subclasses
from serena.util.logging import MemoryLogHandler
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
if TYPE_CHECKING:
from serena.gui_log_viewer import GuiLogViewer
@@ -43,6 +43,10 @@ class ProjectNotFoundError(Exception):
class AvailableTools:
"""
Represents the set of available/exposed tools of a SerenaAgent.
"""
def __init__(self, tools: list[Tool]):
"""
:param tools: the list of available tools
@@ -59,6 +63,94 @@ class AvailableTools:
return len(self.tools)
class ToolSet:
"""
Represents a set of tools by their names.
"""
LEGACY_TOOL_NAME_MAPPING = {"replace_regex": ReplaceContentTool.get_name_from_cls()}
"""
maps legacy tool names to their new names for backward compatibility
"""
def __init__(self, tool_names: set[str]) -> None:
self._tool_names = tool_names
@classmethod
def default(cls) -> "ToolSet":
"""
:return: the default tool set, which contains all tools that are enabled by default
"""
from serena.tools import ToolRegistry
return cls(set(ToolRegistry().get_tool_names_default_enabled()))
def apply(self, *tool_inclusion_definitions: "ToolInclusionDefinition") -> "ToolSet":
"""
Applies one or more tool inclusion definitions to this tool set,
resulting in a new tool set.
:param tool_inclusion_definitions: the definitions to apply
:return: a new tool set with the definitions applied
"""
from serena.tools import ToolRegistry
def get_updated_tool_name(tool_name: str) -> str:
"""Retrieves the updated tool name if the provided tool name is deprecated, logging a warning."""
if tool_name in self.LEGACY_TOOL_NAME_MAPPING:
new_tool_name = self.LEGACY_TOOL_NAME_MAPPING[tool_name]
log.warning("Tool name '%s' is deprecated, please use '%s' instead", tool_name, new_tool_name)
return new_tool_name
return tool_name
registry = ToolRegistry()
tool_names = set(self._tool_names)
for definition in tool_inclusion_definitions:
included_tools = []
excluded_tools = []
for included_tool in definition.included_optional_tools:
included_tool = get_updated_tool_name(included_tool)
if not registry.is_valid_tool_name(included_tool):
raise ValueError(f"Invalid tool name '{included_tool}' provided for inclusion")
if included_tool not in tool_names:
tool_names.add(included_tool)
included_tools.append(included_tool)
for excluded_tool in definition.excluded_tools:
excluded_tool = get_updated_tool_name(excluded_tool)
if not registry.is_valid_tool_name(excluded_tool):
raise ValueError(f"Invalid tool name '{excluded_tool}' provided for exclusion")
if excluded_tool in tool_names:
tool_names.remove(excluded_tool)
excluded_tools.append(excluded_tool)
if included_tools:
log.info(f"{definition} included {len(included_tools)} tools: {', '.join(included_tools)}")
if excluded_tools:
log.info(f"{definition} excluded {len(excluded_tools)} tools: {', '.join(excluded_tools)}")
return ToolSet(tool_names)
def without_editing_tools(self) -> "ToolSet":
"""
:return: a new tool set that excludes all tools that can edit
"""
from serena.tools import ToolRegistry
registry = ToolRegistry()
tool_names = set(self._tool_names)
for tool_name in self._tool_names:
if registry.get_tool_class_by_name(tool_name).can_edit():
tool_names.remove(tool_name)
return ToolSet(tool_names)
def get_tool_names(self) -> set[str]:
"""
Returns the names of the tools that are currently included in the tool set.
"""
return self._tool_names
def includes_name(self, tool_name: str) -> bool:
return tool_name in self._tool_names
class SerenaAgent:
def __init__(
self,
@@ -86,7 +178,6 @@ class SerenaAgent:
# project-specific instances, which will be initialized upon project activation
self._active_project: Project | None = None
self.language_server: SolidLanguageServer | None = None
# adjust log level
serena_log_level = self.serena_config.log_level
@@ -127,25 +218,9 @@ class SerenaAgent:
if self._gui_log_viewer is not None:
self._gui_log_viewer.set_tool_names(tool_names)
self._tool_usage_stats: ToolUsageStats | None = None
if self.serena_config.record_tool_usage_stats:
token_count_estimator = RegisteredTokenCountEstimator[self.serena_config.token_count_estimator]
log.info(f"Tool usage statistics recording is enabled with token count estimator: {token_count_estimator.name}.")
self._tool_usage_stats = ToolUsageStats(token_count_estimator)
# start the dashboard (web frontend), registering its log handler
if self.serena_config.web_dashboard:
self._dashboard_thread, port = SerenaDashboardAPI(
get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats
).run_in_thread()
dashboard_url = f"http://127.0.0.1:{port}/dashboard/index.html"
log.info("Serena web dashboard started at %s", dashboard_url)
if self.serena_config.web_dashboard_open_on_launch:
# open the dashboard URL in the default web browser (using a separate process to control
# output redirection)
process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,))
process.start()
process.join(timeout=1)
token_count_estimator = RegisteredTokenCountEstimator[self.serena_config.token_count_estimator]
log.info(f"Will record tool usage statistics with token count estimator: {token_count_estimator.name}.")
self._tool_usage_stats = ToolUsageStats(token_count_estimator)
# log fundamental information
log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})")
@@ -158,8 +233,8 @@ class SerenaAgent:
# determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see),
# limited by the Serena config, the context (which is fixed for the session) and JetBrains mode
tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context]
if self._context.name == RegisteredContext.IDE_ASSISTANT.value:
tool_inclusion_definitions.extend(self._ide_assistant_context_tool_inclusion_definitions(project))
if self._context.single_project:
tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project))
if self.serena_config.jetbrains:
tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains"))
@@ -168,10 +243,8 @@ class SerenaAgent:
log.info(f"Number of exposed tools: {len(self._exposed_tools)}")
# create executor for starting the language server and running tools in another thread
# This executor is used to achieve linear task execution, so it is important to use a single-threaded executor.
self._task_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="SerenaAgentExecutor")
self._task_executor_lock = threading.Lock()
self._task_executor_task_index = 1
# This executor is used to achieve linear task execution
self._task_executor = TaskExecutor("SerenaAgentTaskExecutor")
# Initialize the prompt factory
self.prompt_factory = SerenaPromptFactory()
@@ -192,6 +265,57 @@ class SerenaAgent:
except Exception as e:
log.error(f"Error activating project '{project}' at startup: {e}", exc_info=e)
# start the dashboard (web frontend), registering its log handler
# should be the last thing to happen in the initialization since the dashboard
# may access various parts of the agent
if self.serena_config.web_dashboard:
self._dashboard_thread, port = SerenaDashboardAPI(
get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats
).run_in_thread()
dashboard_url = f"http://127.0.0.1:{port}/dashboard/index.html"
log.info("Serena web dashboard started at %s", dashboard_url)
if self.serena_config.web_dashboard_open_on_launch:
# open the dashboard URL in the default web browser (using a separate process to control
# output redirection)
process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,))
process.start()
process.join(timeout=1)
# inform the GUI window (if any)
if self._gui_log_viewer is not None:
self._gui_log_viewer.set_dashboard_url(dashboard_url)
def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]:
"""
Gets the list of tasks currently running or queued for execution.
The function returns a list of thread-safe TaskInfo objects (specifically created for the caller).
:return: the list of tasks in the execution order (running task first)
"""
return self._task_executor.get_current_tasks()
def get_last_executed_task(self) -> TaskExecutor.TaskInfo | None:
"""
Gets the last executed task.
:return: the last executed task info or None if no task has been executed yet
"""
return self._task_executor.get_last_executed_task()
def get_language_server_manager(self) -> LanguageServerManager | None:
if self._active_project is not None:
return self._active_project.language_server_manager
return None
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!"
)
return language_server_manager
def get_context(self) -> SerenaAgentContext:
return self._context
@@ -208,7 +332,7 @@ class SerenaAgent:
os.environ["COMSPEC"] = "" # force use of default shell
log.info("Adjusting COMSPEC environment variable to use the default shell instead of '%s'", comspec)
def _ide_assistant_context_tool_inclusion_definitions(self, project_root_or_name: str | None) -> list[ToolInclusionDefinition]:
def _single_project_context_tool_inclusion_definitions(self, project_root_or_name: str | None) -> list[ToolInclusionDefinition]:
"""
In the IDE assistant context, the agent is assumed to work on a single project, and we thus
want to apply that project's tool exclusions/inclusions from the get-go, limiting the set
@@ -226,6 +350,9 @@ class SerenaAgent:
# and provide responses to the client immediately.
project = self.load_project_from_path_or_name(project_root_or_name, autogenerate=False)
if project is not None:
log.info(
"Applying tool inclusion/exclusion definitions for single-project context based on project '%s'", project.project_name
)
tool_inclusion_definitions.append(
ToolInclusionDefinition(
excluded_tools=[ActivateProjectTool.get_name_from_cls(), GetCurrentConfigTool.get_name_from_cls()]
@@ -234,18 +361,15 @@ class SerenaAgent:
tool_inclusion_definitions.append(project.project_config)
return tool_inclusion_definitions
def record_tool_usage_if_enabled(self, input_kwargs: dict, tool_result: str | dict, tool: Tool) -> None:
def record_tool_usage(self, input_kwargs: dict, tool_result: str | dict, tool: Tool) -> None:
"""
Record the usage of a tool with the given input and output strings if tool usage statistics recording is enabled.
"""
tool_name = tool.get_name()
if self._tool_usage_stats is not None:
input_str = str(input_kwargs)
output_str = str(tool_result)
log.debug(f"Recording tool usage for tool '{tool_name}'")
self._tool_usage_stats.record_tool_usage(tool_name, input_str, output_str)
else:
log.debug(f"Tool usage statistics recording is disabled, not recording usage of '{tool_name}'.")
input_str = str(input_kwargs)
output_str = str(tool_result)
log.debug(f"Recording tool usage for tool '{tool_name}'")
self._tool_usage_stats.record_tool_usage(tool_name, input_str, output_str)
@staticmethod
def _open_dashboard(url: str) -> None:
@@ -352,36 +476,33 @@ class SerenaAgent:
log.info(f"Active tools ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}")
def issue_task(self, task: Callable[[], Any], name: str | None = None) -> Future:
def issue_task(
self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None
) -> TaskExecutor.Task[T]:
"""
Issue a task to the executor for asynchronous execution.
It is ensured that tasks are executed in the order they are issued, one after another.
:param task: the task to execute
:param name: the name of the task for logging purposes; if None, use the task function's name
:return: a Future object representing the execution of the task
:param logged: whether to log management of the task; if False, only errors will be logged
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
:return: the task object, through which the task's future result can be accessed
"""
with self._task_executor_lock:
task_name = f"Task-{self._task_executor_task_index}[{name or task.__name__}]"
self._task_executor_task_index += 1
return self._task_executor.issue_task(task, name=name, logged=logged, timeout=timeout)
def task_execution_wrapper() -> Any:
with LogTime(task_name, logger=log):
return task()
log.info(f"Scheduling {task_name}")
return self._task_executor.submit(task_execution_wrapper)
def execute_task(self, task: Callable[[], T]) -> T:
def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T:
"""
Executes the given task synchronously via the agent's task executor.
This is useful for tasks that need to be executed immediately and whose results are needed right away.
:param task: the task to execute
:param name: the name of the task for logging purposes; if None, use the task function's name
:param logged: whether to log management of the task; if False, only errors will be logged
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
:return: the result of the task execution
"""
future = self.issue_task(task)
return future.result()
return self._task_executor.execute_task(task, name=name, logged=logged, timeout=timeout)
def is_using_language_server(self) -> bool:
"""
@@ -394,15 +515,14 @@ class SerenaAgent:
self._active_project = project
self._update_active_tools()
def init_language_server() -> None:
def init_language_server_manager() -> None:
# start the language server
with LogTime("Language server initialization", logger=log):
self.reset_language_server()
assert self.language_server is not None
self.reset_language_server_manager()
# initialize the language server in the background (if in language server mode)
if self.is_using_language_server():
self.issue_task(init_language_server)
self.issue_task(init_language_server_manager)
if self._project_activation_callback is not None:
self._project_activation_callback()
@@ -509,12 +629,9 @@ class SerenaAgent:
return result_str
def is_language_server_running(self) -> bool:
return self.language_server is not None and self.language_server.is_running()
def reset_language_server(self) -> None:
def reset_language_server_manager(self) -> None:
"""
Starts/resets the language server for the current project
Starts/resets the language server manager for the current project
"""
tool_timeout = self.serena_config.tool_timeout
if tool_timeout is None or tool_timeout < 0:
@@ -524,27 +641,32 @@ class SerenaAgent:
raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds")
ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout
# stop the language server if it is running
if self.is_language_server_running():
assert self.language_server is not None
log.info(f"Stopping the current language server at {self.language_server.repository_root_path} ...")
self.language_server.stop()
self.language_server = None
# instantiate and start the language server
assert self._active_project is not None
self.language_server = self._active_project.create_language_server(
# instantiate and start the necessary language servers
self.get_active_project_or_raise().create_language_server_manager(
log_level=self.serena_config.log_level,
ls_timeout=ls_timeout,
trace_lsp_communication=self.serena_config.trace_lsp_communication,
ls_specific_settings=self.serena_config.ls_specific_settings,
)
log.info(f"Starting the language server for {self._active_project.project_name}")
self.language_server.start()
if not self.language_server.is_running():
raise RuntimeError(
f"Failed to start the language server for {self._active_project.project_name} at {self._active_project.project_root}"
)
def add_language(self, language: Language) -> None:
"""
Adds a new language to the active project, spawning the respective language server and updating the project configuration.
The addition is scheduled via the agent's task executor and executed synchronously, i.e. the method returns
when the addition is complete.
:param language: the language to add
"""
self.execute_task(lambda: self.get_active_project_or_raise().add_language(language), name=f"AddLanguage:{language.value}")
def remove_language(self, language: Language) -> None:
"""
Removes a language from the active project, shutting down the respective language server and updating the project configuration.
The removal is scheduled via the agent's task executor and executed asynchronously.
:param language: the language to remove
"""
self.issue_task(lambda: self.get_active_project_or_raise().remove_language(language), name=f"RemoveLanguage:{language.value}")
def get_tool(self, tool_class: type[TTool]) -> TTool:
return self._all_tools[tool_class] # type: ignore
@@ -559,11 +681,9 @@ class SerenaAgent:
if not hasattr(self, "_is_initialized"):
return
log.info("SerenaAgent is shutting down ...")
if self.is_language_server_running():
log.info("Stopping the language server ...")
assert self.language_server is not None
self.language_server.save_cache()
self.language_server.stop()
if self._active_project is not None:
self._active_project.shutdown()
self._active_project = None
if self._gui_log_viewer:
log.info("Stopping the GUI log window ...")
self._gui_log_viewer.stop()
+10 -12
View File
@@ -6,9 +6,9 @@ from pathlib import Path
from typing import Any
from agno.agent import Agent
from agno.memory import AgentMemory
from agno.db.sqlite import SqliteDb
from agno.memory import MemoryManager
from agno.models.base import Model
from agno.storage.sqlite import SqliteStorage
from agno.tools.function import Function
from agno.tools.toolkit import Toolkit
from dotenv import load_dotenv
@@ -111,10 +111,9 @@ class SerenaAgnoAgentProvider:
raise
# Even though we don't want to keep history between sessions,
# for agno-ui to work as a conversation, we use a persistent storage on disk.
# This storage should be deleted between sessions.
# for agno-ui to work as a conversation, we use a persistent database on disk.
# This database should be deleted between sessions.
# Note that this might collide with custom options for the agent, like adding vector-search based tools.
# See here for an explanation: https://www.reddit.com/r/agno/comments/1jk6qea/regarding_the_built_in_memory/
sql_db_path = (Path("temp") / "agno_agent_storage.db").absolute()
sql_db_path.parent.mkdir(exist_ok=True)
# delete the db file if it exists
@@ -125,19 +124,18 @@ class SerenaAgnoAgentProvider:
agno_agent = Agent(
name="Serena",
model=model,
# See explanation above on why storage is needed
storage=SqliteStorage(table_name="serena_agent_sessions", db_file=str(sql_db_path)),
# See explanation above on why database is needed
db=SqliteDb(db_file=str(sql_db_path)),
description="A fully-featured coding assistant",
tools=[SerenaAgnoToolkit(serena_agent)],
# The tool calls will be shown in the UI anyway since whether to show them is configurable per tool
# Tool calls will be shown in the UI since that's configurable per tool
# To see detailed logs, you should use the serena logger (configure it in the project file path)
show_tool_calls=False,
markdown=True,
system_message=serena_agent.create_system_prompt(),
telemetry=False,
memory=AgentMemory(),
add_history_to_messages=True,
num_history_responses=100, # you might want to adjust this (expense vs. history awareness)
memory_manager=MemoryManager(),
add_history_to_context=True,
num_history_runs=100, # you might want to adjust this (expense vs. history awareness)
)
cls._agent = agno_agent
log.info(f"Agent instantiated: {agno_agent}")
+17 -1
View File
@@ -69,12 +69,26 @@ class AnthropicTokenCount(TokenCountEstimator):
return self._send_count_tokens_request(text).input_tokens
class CharCountEstimator(TokenCountEstimator):
"""
A naive character count estimator that estimates tokens based on character count.
"""
def __init__(self, avg_chars_per_token: int = 4):
self._avg_chars_per_token = avg_chars_per_token
def estimate_token_count(self, text: str) -> int:
# Assuming an average of 4 characters per token
return len(text) // self._avg_chars_per_token
_registered_token_estimator_instances_cache: dict[RegisteredTokenCountEstimator, TokenCountEstimator] = {}
class RegisteredTokenCountEstimator(Enum):
TIKTOKEN_GPT4O = "TIKTOKEN_GPT4O"
ANTHROPIC_CLAUDE_SONNET_4 = "ANTHROPIC_CLAUDE_SONNET_4"
CHAR_COUNT = "CHAR_COUNT"
@classmethod
def get_valid_names(cls) -> list[str]:
@@ -89,8 +103,10 @@ class RegisteredTokenCountEstimator(Enum):
return TiktokenCountEstimator(model_name="gpt-4o")
case RegisteredTokenCountEstimator.ANTHROPIC_CLAUDE_SONNET_4:
return AnthropicTokenCount(model_name="claude-sonnet-4-20250514")
case RegisteredTokenCountEstimator.CHAR_COUNT:
return CharCountEstimator(avg_chars_per_token=4)
case _:
raise ValueError(f"Unknown token count estimator: {self.value}")
raise ValueError(f"Unknown token count estimator: {self}")
def load_estimator(self) -> TokenCountEstimator:
estimator_instance = _registered_token_estimator_instances_cache.get(self)
+118 -42
View File
@@ -1,3 +1,4 @@
import collections
import glob
import json
import os
@@ -11,6 +12,7 @@ from typing import Any, Literal
import click
from sensai.util import logging
from sensai.util.logging import FileLoggerContext, datetime_tag
from sensai.util.string import dict_string
from tqdm import tqdm
from serena.agent import SerenaAgent
@@ -414,26 +416,74 @@ class ProjectCommands(AutoRegisteringGroup):
)
@staticmethod
@click.command("generate-yml", help="Generate a project.yml file.")
@click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd())
@click.option("--language", type=str, default=None, help="Programming language; inferred if not specified.")
def generate_yml(project_path: str, language: str | None = None) -> None:
def _create_project(project_path: str, name: str | None, language: tuple[str, ...]) -> ProjectConfig:
"""
Helper method to create a project configuration file.
:param project_path: Path to the project directory
:param name: Optional project name (defaults to directory name if not specified)
:param language: Tuple of language names
:return: The generated ProjectConfig instance
:raises FileExistsError: If project.yml already exists
:raises ValueError: If an unsupported language is specified
"""
yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml())
if os.path.exists(yml_path):
raise FileExistsError(f"Project file {yml_path} already exists.")
lang_inst = None
languages: list[Language] = []
if language:
try:
lang_inst = Language[language.upper()]
except KeyError:
all_langs = [l.name.lower() for l in Language.iter_all(include_experimental=True)]
raise ValueError(f"Unknown language '{language}'. Supported: {all_langs}")
generated_conf = ProjectConfig.autogenerate(project_root=project_path, project_language=lang_inst)
print(f"Generated project.yml with language {generated_conf.language.value} at {yml_path}.")
for lang in language:
try:
languages.append(Language(lang.lower()))
except ValueError:
all_langs = [l.value for l in Language]
raise ValueError(f"Unknown language '{lang}'. Supported: {all_langs}")
generated_conf = ProjectConfig.autogenerate(
project_root=project_path, project_name=name, languages=languages if languages else None
)
return generated_conf
@staticmethod
@click.command("index", help="Index a project by saving symbols to the LSP cache.")
@click.command("create", help="Create a new Serena project configuration.")
@click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd())
@click.option("--name", type=str, default=None, help="Project name; defaults to directory name if not specified.")
@click.option(
"--language", type=str, multiple=True, help="Programming language(s); inferred if not specified. Can be passed multiple times."
)
@click.option("--index", is_flag=True, help="Index the project after creation.")
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="WARNING",
help="Log level for indexing (only used if --index is set).",
)
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file (only used if --index is set).")
def create(project_path: str, name: str | None, language: tuple[str, ...], index: bool, log_level: str, timeout: float) -> None:
try:
generated_conf = ProjectCommands._create_project(project_path, name, language)
yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml())
click.echo(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.")
if index:
click.echo("Indexing project...")
ProjectCommands._index_project(project_path, log_level, timeout=timeout)
except FileExistsError as e:
raise click.ClickException(f"Project already exists: {e}\nUse 'serena project index' to index an existing project.")
except ValueError as e:
raise click.ClickException(str(e))
@staticmethod
@click.command("index", help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.")
@click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False)
@click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).")
@click.option(
"--language",
type=str,
multiple=True,
help="Programming language(s) (only used if auto-creating project.yml). Inferred if not specified.",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
@@ -441,7 +491,20 @@ class ProjectCommands(AutoRegisteringGroup):
help="Log level for indexing.",
)
@click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.")
def index(project: str, log_level: str, timeout: float) -> None:
def index(project: str, name: str | None, language: tuple[str, ...], log_level: str, timeout: float) -> None:
# Check if project.yml exists, if not auto-create it
yml_path = os.path.join(project, ProjectConfig.rel_path_to_project_yml())
if not os.path.exists(yml_path):
click.echo(f"Project configuration not found at {yml_path}. Auto-creating...")
try:
generated_conf = ProjectCommands._create_project(project, name, language)
click.echo(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.")
except FileExistsError:
# Race condition - file was created between check and creation
pass
except ValueError as e:
raise click.ClickException(str(e))
ProjectCommands._index_project(project, log_level, timeout=timeout)
@staticmethod
@@ -459,33 +522,42 @@ class ProjectCommands(AutoRegisteringGroup):
logging.configure(level=lvl)
serena_config = SerenaConfig.from_config_file()
proj = Project.load(os.path.abspath(project))
click.echo(f"Indexing symbols in project {project}…")
ls = proj.create_language_server(log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings)
log_file = os.path.join(project, ".serena", "logs", "indexing.txt")
click.echo(f"Indexing symbols in project {project} …")
ls_mgr = proj.create_language_server_manager(
log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings
)
try:
log_file = os.path.join(project, ".serena", "logs", "indexing.txt")
collected_exceptions: list[Exception] = []
files_failed = []
with ls.start_server():
files = proj.gather_source_files()
collected_exceptions: list[Exception] = []
files_failed = []
language_file_counts: dict[Language, int] = collections.defaultdict(lambda: 0)
for i, f in enumerate(tqdm(files, desc="Indexing")):
try:
ls.request_document_symbols(f, include_body=False)
ls.request_document_symbols(f, include_body=True)
ls = ls_mgr.get_language_server(f)
ls.request_document_symbols(f)
language_file_counts[ls.language] += 1
except Exception as e:
log.error(f"Failed to index {f}, continuing.")
collected_exceptions.append(e)
files_failed.append(f)
if (i + 1) % 10 == 0:
ls.save_cache()
ls.save_cache()
click.echo(f"Symbols saved to {ls.cache_path}")
if len(files_failed) > 0:
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, "w") as f:
for file, exception in zip(files_failed, collected_exceptions, strict=True):
f.write(f"{file}\n")
f.write(f"{exception}\n")
click.echo(f"Failed to index {len(files_failed)} files, see:\n{log_file}")
ls_mgr.save_all_caches()
reported_language_file_counts = {k.value: v for k, v in language_file_counts.items()}
click.echo(f"Indexed files per language: {dict_string(reported_language_file_counts, brackets=None)}")
ls_mgr.save_all_caches()
if len(files_failed) > 0:
os.makedirs(os.path.dirname(log_file), exist_ok=True)
with open(log_file, "w") as f:
for file, exception in zip(files_failed, collected_exceptions, strict=True):
f.write(f"{file}\n")
f.write(f"{exception}\n")
click.echo(f"Failed to index {len(files_failed)} files, see:\n{log_file}")
finally:
ls_mgr.stop_all()
@staticmethod
@click.command("is_ignored_path", help="Check if a path is ignored by the project configuration.")
@@ -522,16 +594,20 @@ class ProjectCommands(AutoRegisteringGroup):
if proj.is_ignored_path(file, ignore_non_source_files=True):
click.echo(f"'{file}' is ignored or declared as non-code file by the project configuration, won't index.")
exit(1)
ls = proj.create_language_server()
with ls.start_server():
symbols, _ = ls.request_document_symbols(file, include_body=False)
ls.request_document_symbols(file, include_body=True)
if verbose:
click.echo(f"Symbols in file '{file}':")
for symbol in symbols:
click.echo(f" - {symbol['name']} at line {symbol['selectionRange']['start']['line']} of kind {symbol['kind']}")
ls.save_cache()
click.echo(f"Successfully indexed file '{file}', {len(symbols)} symbols saved to {ls.cache_path}.")
ls_mgr = proj.create_language_server_manager()
try:
for ls in ls_mgr.iter_language_servers():
click.echo(f"Indexing for language {ls.language.value} …")
document_symbols = ls.request_document_symbols(file)
symbols, _ = document_symbols.get_all_symbols_and_roots()
if verbose:
click.echo(f"Symbols in file '{file}':")
for symbol in symbols:
click.echo(f" - {symbol['name']} at line {symbol['selectionRange']['start']['line']} of kind {symbol['kind']}")
ls.save_cache()
click.echo(f"Successfully indexed file '{file}', {len(symbols)} symbols saved to cache in {ls.cache_dir}.")
finally:
ls_mgr.stop_all()
@staticmethod
@click.command("health-check", help="Perform a comprehensive health check of the project's tools and language server.")
+12 -10
View File
@@ -231,12 +231,11 @@ class CodeEditor(Generic[TSymbol], ABC):
class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
def __init__(self, symbol_retriever: LanguageServerSymbolRetriever, agent: Optional["SerenaAgent"] = None):
super().__init__(project_root=symbol_retriever.get_language_server().repository_root_path, agent=agent)
super().__init__(project_root=symbol_retriever.get_root_path(), agent=agent)
self._symbol_retriever = symbol_retriever
@property
def _lang_server(self) -> SolidLanguageServer:
return self._symbol_retriever.get_language_server()
def _get_language_server(self, relative_path: str) -> SolidLanguageServer:
return self._symbol_retriever.get_language_server(relative_path)
class EditedFile(CodeEditor.EditedFile):
def __init__(self, lang_server: SolidLanguageServer, relative_path: str, file_buffer: LSPFileBuffer):
@@ -258,12 +257,14 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
@contextmanager
def _open_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]:
with self._lang_server.open_file(relative_path) as file_buffer:
yield self.EditedFile(self._lang_server, relative_path, file_buffer)
lang_server = self._get_language_server(relative_path)
with lang_server.open_file(relative_path) as file_buffer:
yield self.EditedFile(lang_server, relative_path, file_buffer)
def _get_code_file_content(self, relative_path: str) -> str:
"""Get the content of a file using the language server."""
return self._lang_server.language_server.retrieve_full_file_content(relative_path)
lang_server = self._get_language_server(relative_path)
return lang_server.language_server.retrieve_full_file_content(relative_path)
def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> LanguageServerSymbol:
symbol_candidates = self._symbol_retriever.find_by_name(name_path, within_relative_path=relative_file_path)
@@ -289,7 +290,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
# Handle the 'changes' format (URI -> list of TextEdits)
for uri, edits in uri_to_edits.items():
file_path = PathUtils.uri_to_path(uri)
relative_path = os.path.relpath(file_path, self._lang_server.repository_root_path)
relative_path = os.path.relpath(file_path, self._symbol_retriever.get_root_path())
modified_relative_paths.append(relative_path)
with self._edited_file_context(relative_path) as edited_file:
edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file)
@@ -305,12 +306,13 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
assert symbol.location.line is not None
assert symbol.location.column is not None
rename_result = self._lang_server.request_rename_symbol_edit(
lang_server = self._get_language_server(relative_file_path)
rename_result = lang_server.request_rename_symbol_edit(
relative_file_path=relative_file_path, line=symbol.location.line, column=symbol.location.column, new_name=new_name
)
if rename_result is None:
raise ValueError(
f"Language server for {self._lang_server.language_id} returned no rename edits for symbol '{name_path}'. "
f"Language server for {lang_server.language_id} returned no rename edits for symbol '{name_path}'. "
f"The symbol might not support renaming."
)
modified_files = self._apply_workspace_edit(rename_result)
+16 -1
View File
@@ -125,14 +125,29 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin):
"""
name: str
"""the name of the context"""
prompt: str
"""
a Jinja2 template for the generation of the system prompt.
It is formatted by the agent (see SerenaAgent._format_prompt()).
"""
description: str = ""
tool_description_overrides: dict[str, str] = field(default_factory=dict)
"""Maps tool names to custom descriptions, default descriptions are extracted from the tool docstrings."""
"""
maps tool names to custom descriptions, default descriptions are extracted from the tool docstrings.
"""
single_project: bool = False
"""
whether to assume that Serena shall only work on a single project in this context (provided that a project is given
when Serena is started).
If set to true and a project is provided at startup, the set of tools is limited to those required by the project's
concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal.
The `activate_project` tool will, therefore, be disabled in this case, as project switching is not allowed.
"""
def _tostring_includes(self) -> list[str]:
return ["name"]
+80 -100
View File
@@ -2,6 +2,7 @@
The Serena Model Context Protocol (MCP) Server
"""
import dataclasses
import os
import shutil
from collections.abc import Iterable
@@ -40,6 +41,8 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
T = TypeVar("T")
DEFAULT_TOOL_TIMEOUT: float = 240
DictType = dict | CommentedMap
TDict = TypeVar("TDict", bound=DictType)
@singleton
@@ -66,72 +69,6 @@ class SerenaPaths:
# TODO: Paths from constants.py should be moved here
class ToolSet:
def __init__(self, tool_names: set[str]) -> None:
self._tool_names = tool_names
@classmethod
def default(cls) -> "ToolSet":
"""
:return: the default tool set, which contains all tools that are enabled by default
"""
from serena.tools import ToolRegistry
return cls(set(ToolRegistry().get_tool_names_default_enabled()))
def apply(self, *tool_inclusion_definitions: "ToolInclusionDefinition") -> "ToolSet":
"""
:param tool_inclusion_definitions: the definitions to apply
:return: a new tool set with the definitions applied
"""
from serena.tools import ToolRegistry
registry = ToolRegistry()
tool_names = set(self._tool_names)
for definition in tool_inclusion_definitions:
included_tools = []
excluded_tools = []
for included_tool in definition.included_optional_tools:
if not registry.is_valid_tool_name(included_tool):
raise ValueError(f"Invalid tool name '{included_tool}' provided for inclusion")
if included_tool not in tool_names:
tool_names.add(included_tool)
included_tools.append(included_tool)
for excluded_tool in definition.excluded_tools:
if not registry.is_valid_tool_name(excluded_tool):
raise ValueError(f"Invalid tool name '{excluded_tool}' provided for exclusion")
if excluded_tool in tool_names:
tool_names.remove(excluded_tool)
excluded_tools.append(excluded_tool)
if included_tools:
log.info(f"{definition} included {len(included_tools)} tools: {', '.join(included_tools)}")
if excluded_tools:
log.info(f"{definition} excluded {len(excluded_tools)} tools: {', '.join(excluded_tools)}")
return ToolSet(tool_names)
def without_editing_tools(self) -> "ToolSet":
"""
:return: a new tool set that excludes all tools that can edit
"""
from serena.tools import ToolRegistry
registry = ToolRegistry()
tool_names = set(self._tool_names)
for tool_name in self._tool_names:
if registry.get_tool_class_by_name(tool_name).can_edit():
tool_names.remove(tool_name)
return ToolSet(tool_names)
def get_tool_names(self) -> set[str]:
"""
Returns the names of the tools that are currently included in the tool set.
"""
return self._tool_names
def includes_name(self, tool_name: str) -> bool:
return tool_name in self._tool_names
@dataclass
class ToolInclusionDefinition:
excluded_tools: Iterable[str] = ()
@@ -162,7 +99,7 @@ def is_running_in_docker() -> bool:
@dataclass(kw_only=True)
class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
project_name: str
language: Language
languages: list[Language]
ignored_paths: list[str] = field(default_factory=list)
read_only: bool = False
ignore_all_files_in_gitignore: bool = True
@@ -176,7 +113,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
@classmethod
def autogenerate(
cls, project_root: str | Path, project_name: str | None = None, project_language: Language | None = None, save_to_disk: bool = True
cls, project_root: str | Path, project_name: str | None = None, languages: list[Language] | None = None, save_to_disk: bool = True
) -> Self:
"""
Autogenerate a project configuration for a given project root.
@@ -184,7 +121,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
:param project_root: the path to the project root
:param project_name: the name of the project; if None, the name of the project will be the name of the directory
containing the project
:param project_language: the programming language of the project; if None, it will be determined automatically
:param languages: the languages of the project; if None, they will be determined automatically
:param save_to_disk: whether to save the project configuration to disk
:return: the project configuration
"""
@@ -193,7 +130,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
raise FileNotFoundError(f"Project root not found: {project_root}")
with LogTime("Project configuration auto-generation", logger=log):
project_name = project_name or project_root.name
if project_language is None:
if languages is None:
language_composition = determine_programming_language_composition(str(project_root))
if len(language_composition) == 0:
raise ValueError(
@@ -208,11 +145,12 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
)
# find the language with the highest percentage
dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang])
languages_to_use = [dominant_language]
else:
dominant_language = project_language.value
config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True)
languages_to_use = [lang.value for lang in languages]
config_with_comments = cls.load_commented_map(PROJECT_TEMPLATE_FILE)
config_with_comments["project_name"] = project_name
config_with_comments["language"] = dominant_language
config_with_comments["languages"] = languages_to_use
if save_to_disk:
save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True)
return cls._from_dict(config_with_comments)
@@ -221,33 +159,79 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
def rel_path_to_project_yml(cls) -> str:
return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE)
@classmethod
def _apply_defaults_to_dict(cls, data: TDict) -> TDict:
# apply defaults for new fields
data["languages"] = data.get("languages", [])
data["ignored_paths"] = data.get("ignored_paths", [])
data["excluded_tools"] = data.get("excluded_tools", [])
data["included_optional_tools"] = data.get("included_optional_tools", [])
data["read_only"] = data.get("read_only", False)
data["ignore_all_files_in_gitignore"] = data.get("ignore_all_files_in_gitignore", True)
data["initial_prompt"] = data.get("initial_prompt", "")
data["encoding"] = data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING)
# backward compatibility: handle single "language" field
if len(data["languages"]) == 0 and "language" in data:
data["languages"] = [data["language"]]
if "language" in data:
del data["language"]
return data
@classmethod
def load_commented_map(cls, yml_path: str) -> CommentedMap:
"""
Load the project configuration as a CommentedMap, preserving comments and ensuring
completeness of the configuration by applying default values for missing fields
and backward compatibility adjustments.
:param yml_path: the path to the project.yml file
:return: a CommentedMap representing a full project configuration
"""
data = load_yaml(yml_path, preserve_comments=True)
return cls._apply_defaults_to_dict(data)
@classmethod
def _from_dict(cls, data: dict[str, Any]) -> Self:
"""
Create a ProjectConfig instance from a configuration dictionary
Create a ProjectConfig instance from a (full) configuration dictionary
"""
language_str = data["language"].lower()
project_name = data["project_name"]
# backwards compatibility
if language_str == "javascript":
log.warning(f"Found deprecated project language `javascript` in project {project_name}, please change to `typescript`")
language_str = "typescript"
try:
language = Language(language_str)
except ValueError as e:
raise ValueError(f"Invalid language: {data['language']}.\nValid languages are: {[l.value for l in Language]}") from e
lang_name_mapping = {"javascript": "typescript"}
languages: list[Language] = []
for language_str in data["languages"]:
orig_language_str = language_str
try:
language_str = language_str.lower()
if language_str in lang_name_mapping:
language_str = lang_name_mapping[language_str]
language = Language(language_str)
languages.append(language)
except ValueError as e:
raise ValueError(
f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}"
) from e
return cls(
project_name=project_name,
language=language,
ignored_paths=data.get("ignored_paths", []),
excluded_tools=data.get("excluded_tools", []),
included_optional_tools=data.get("included_optional_tools", []),
read_only=data.get("read_only", False),
ignore_all_files_in_gitignore=data.get("ignore_all_files_in_gitignore", True),
initial_prompt=data.get("initial_prompt", ""),
encoding=data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING),
project_name=data["project_name"],
languages=languages,
ignored_paths=data["ignored_paths"],
excluded_tools=data["excluded_tools"],
included_optional_tools=data["included_optional_tools"],
read_only=data["read_only"],
ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"],
initial_prompt=data["initial_prompt"],
encoding=data["encoding"],
)
def to_yaml_dict(self) -> dict:
"""
:return: a yaml-serializable dictionary representation of this configuration
"""
d = dataclasses.asdict(self)
d["languages"] = [lang.value for lang in self.languages]
return d
@classmethod
def load(cls, project_root: Path | str, autogenerate: bool = False) -> Self:
"""
@@ -260,8 +244,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
return cls.autogenerate(project_root)
else:
raise FileNotFoundError(f"Project configuration file not found: {yaml_path}")
with open(yaml_path, encoding=SERENA_FILE_ENCODING) as f:
yaml_data = yaml.safe_load(f)
yaml_data = cls.load_commented_map(str(yaml_path))
if "project_name" not in yaml_data:
yaml_data["project_name"] = project_root.name
return cls._from_dict(yaml_data)
@@ -340,10 +323,8 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
"""
whether to apply JetBrains mode
"""
record_tool_usage_stats: bool = False
"""Whether to record tool usage statistics, they will be shown in the web dashboard if recording is active.
"""
token_count_estimator: str = RegisteredTokenCountEstimator.TIKTOKEN_GPT4O.name
token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name
"""Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics.
See the `RegisteredTokenCountEstimator` enum for available options.
@@ -457,7 +438,6 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
instance.excluded_tools = loaded_commented_yaml.get("excluded_tools", [])
instance.included_optional_tools = loaded_commented_yaml.get("included_optional_tools", [])
instance.jetbrains = loaded_commented_yaml.get("jetbrains", False)
instance.record_tool_usage_stats = loaded_commented_yaml.get("record_tool_usage_stats", False)
instance.token_count_estimator = loaded_commented_yaml.get(
"token_count_estimator", RegisteredTokenCountEstimator.TIKTOKEN_GPT4O.name
)
+410 -1
View File
@@ -2,7 +2,7 @@ import os
import socket
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Self
from flask import Flask, Response, request, send_from_directory
from pydantic import BaseModel
@@ -10,6 +10,7 @@ from sensai.util import logging
from serena.analytics import ToolUsageStats
from serena.constants import SERENA_DASHBOARD_DIR
from serena.task_executor import TaskExecutor
from serena.util.logging import MemoryLogHandler
if TYPE_CHECKING:
@@ -39,6 +40,82 @@ class ResponseToolStats(BaseModel):
stats: dict[str, dict[str, int]]
class ResponseConfigOverview(BaseModel):
active_project: dict[str, str | None]
context: dict[str, str]
modes: list[dict[str, str]]
active_tools: list[str]
tool_stats_summary: dict[str, dict[str, int]]
registered_projects: list[dict[str, str | bool]]
available_tools: list[dict[str, str | bool]]
available_modes: list[dict[str, str | bool]]
available_contexts: list[dict[str, str | bool]]
available_memories: list[str] | None
jetbrains_mode: bool
languages: list[str]
encoding: str | None
class ResponseAvailableLanguages(BaseModel):
languages: list[str]
class RequestAddLanguage(BaseModel):
language: str
class RequestRemoveLanguage(BaseModel):
language: str
class RequestGetMemory(BaseModel):
memory_name: str
class ResponseGetMemory(BaseModel):
content: str
memory_name: str
class RequestSaveMemory(BaseModel):
memory_name: str
content: str
class RequestDeleteMemory(BaseModel):
memory_name: str
class ResponseGetSerenaConfig(BaseModel):
content: str
class RequestSaveSerenaConfig(BaseModel):
content: str
class RequestCancelTaskExecution(BaseModel):
task_id: int
class QueuedExecution(BaseModel):
task_id: int
is_running: bool
name: str
finished_successfully: bool
logged: bool
@classmethod
def from_task_info(cls, task_info: TaskExecutor.TaskInfo) -> Self:
return cls(
task_id=task_info.task_id,
is_running=task_info.is_running,
name=task_info.name,
finished_successfully=task_info.finished_successfully(),
logged=task_info.logged,
)
class SerenaDashboardAPI:
log = logging.getLogger(__qualname__)
@@ -73,6 +150,11 @@ class SerenaDashboardAPI:
return send_from_directory(SERENA_DASHBOARD_DIR, "index.html")
# API routes
@self._app.route("/heartbeat", methods=["GET"])
def get_heartbeat() -> dict[str, Any]:
return {"status": "alive"}
@self._app.route("/get_log_messages", methods=["POST"])
def get_log_messages() -> dict[str, Any]:
request_data = request.get_json()
@@ -104,11 +186,136 @@ class SerenaDashboardAPI:
estimator_name = self._tool_usage_stats.token_estimator_name if self._tool_usage_stats else "unknown"
return {"token_count_estimator_name": estimator_name}
@self._app.route("/get_config_overview", methods=["GET"])
def get_config_overview() -> dict[str, Any]:
result = self._agent.execute_task(self._get_config_overview, logged=False)
return result.model_dump()
@self._app.route("/shutdown", methods=["PUT"])
def shutdown() -> dict[str, str]:
self._shutdown()
return {"status": "shutting down"}
@self._app.route("/get_available_languages", methods=["GET"])
def get_available_languages() -> dict[str, Any]:
result = self._get_available_languages()
return result.model_dump()
@self._app.route("/add_language", methods=["POST"])
def add_language() -> dict[str, str]:
request_data = request.get_json()
if not request_data:
return {"status": "error", "message": "No data provided"}
request_add_language = RequestAddLanguage.model_validate(request_data)
try:
self._add_language(request_add_language)
return {"status": "success", "message": f"Language {request_add_language.language} added successfully"}
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/remove_language", methods=["POST"])
def remove_language() -> dict[str, str]:
request_data = request.get_json()
if not request_data:
return {"status": "error", "message": "No data provided"}
request_remove_language = RequestRemoveLanguage.model_validate(request_data)
try:
self._remove_language(request_remove_language)
return {"status": "success", "message": f"Language {request_remove_language.language} removed successfully"}
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/get_memory", methods=["POST"])
def get_memory() -> dict[str, Any]:
request_data = request.get_json()
if not request_data:
return {"status": "error", "message": "No data provided"}
request_get_memory = RequestGetMemory.model_validate(request_data)
try:
result = self._get_memory(request_get_memory)
return result.model_dump()
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/save_memory", methods=["POST"])
def save_memory() -> dict[str, str]:
request_data = request.get_json()
if not request_data:
return {"status": "error", "message": "No data provided"}
request_save_memory = RequestSaveMemory.model_validate(request_data)
try:
self._save_memory(request_save_memory)
return {"status": "success", "message": f"Memory {request_save_memory.memory_name} saved successfully"}
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/delete_memory", methods=["POST"])
def delete_memory() -> dict[str, str]:
request_data = request.get_json()
if not request_data:
return {"status": "error", "message": "No data provided"}
request_delete_memory = RequestDeleteMemory.model_validate(request_data)
try:
self._delete_memory(request_delete_memory)
return {"status": "success", "message": f"Memory {request_delete_memory.memory_name} deleted successfully"}
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:
result = self._get_serena_config()
return result.model_dump()
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/save_serena_config", methods=["POST"])
def save_serena_config() -> dict[str, str]:
request_data = request.get_json()
if not request_data:
return {"status": "error", "message": "No data provided"}
request_save_config = RequestSaveSerenaConfig.model_validate(request_data)
try:
self._save_serena_config(request_save_config)
return {"status": "success", "message": "Serena config saved successfully"}
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/queued_task_executions", methods=["GET"])
def get_queued_executions() -> dict[str, Any]:
try:
current_executions = self._agent.get_current_tasks()
response = [QueuedExecution.from_task_info(task_info).model_dump() for task_info in current_executions]
return {"queued_executions": response, "status": "success"}
except Exception as e:
return {"status": "error", "message": str(e)}
@self._app.route("/cancel_task_execution", methods=["POST"])
def cancel_task_execution() -> dict[str, Any]:
request_data = request.get_json()
try:
request_cancel_task = RequestCancelTaskExecution.model_validate(request_data)
for task in self._agent.get_current_tasks():
if task.task_id == request_cancel_task.task_id:
task.cancel()
return {"status": "success", "was_cancelled": True}
return {
"status": "success",
"was_cancelled": False,
"message": f"Task with id {request_data.get('task_id')} not found, maybe execution was already finished",
}
except Exception as e:
return {"status": "error", "message": str(e), "was_cancelled": False}
@self._app.route("/last_execution", methods=["GET"])
def get_last_execution() -> dict[str, Any]:
try:
last_execution_info = self._agent.get_last_executed_task()
response = QueuedExecution.from_task_info(last_execution_info).model_dump() if last_execution_info is not None else None
return {"last_execution": response, "status": "success"}
except Exception as e:
return {"status": "error", "message": str(e)}
def _get_log_messages(self, request_log: RequestLog) -> ResponseLog:
all_messages = self._memory_log_handler.get_log_messages()
requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else []
@@ -129,6 +336,118 @@ class SerenaDashboardAPI:
if self._tool_usage_stats is not None:
self._tool_usage_stats.clear()
def _get_config_overview(self) -> ResponseConfigOverview:
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
# Get active project info
project = self._agent.get_active_project()
active_project_name = project.project_name if project else None
project_info = {
"name": active_project_name,
"language": ", ".join([l.value for l in project.project_config.languages]) if project else None,
"path": str(project.project_root) if project else None,
}
# Get context info
context = self._agent.get_context()
context_info = {
"name": context.name,
"description": context.description,
"path": SerenaAgentContext.get_path(context.name),
}
# Get active modes
modes = self._agent.get_active_modes()
modes_info = [{"name": mode.name, "description": mode.description, "path": SerenaAgentMode.get_path(mode.name)} for mode in modes]
active_mode_names = [mode.name for mode in modes]
# Get active tools
active_tools = self._agent.get_active_tool_names()
# Get registered projects
registered_projects: list[dict[str, str | bool]] = []
for proj in self._agent.serena_config.projects:
registered_projects.append(
{
"name": proj.project_name,
"path": str(proj.project_root),
"is_active": proj.project_name == active_project_name,
}
)
# Get all available tools (excluding active ones)
all_tool_names = sorted([tool.get_name_from_cls() for tool in self._agent._all_tools.values()])
available_tools: list[dict[str, str | bool]] = []
for tool_name in all_tool_names:
if tool_name not in active_tools:
available_tools.append(
{
"name": tool_name,
"is_active": False,
}
)
# Get all available modes
all_mode_names = SerenaAgentMode.list_registered_mode_names()
available_modes: list[dict[str, str | bool]] = []
for mode_name in all_mode_names:
available_modes.append(
{
"name": mode_name,
"is_active": mode_name in active_mode_names,
"path": SerenaAgentMode.get_path(mode_name),
}
)
# Get all available contexts
all_context_names = SerenaAgentContext.list_registered_context_names()
available_contexts: list[dict[str, str | bool]] = []
for context_name in all_context_names:
available_contexts.append(
{
"name": context_name,
"is_active": context_name == context.name,
"path": SerenaAgentContext.get_path(context_name),
}
)
# Get basic tool stats (just num_calls for overview)
tool_stats_summary = {}
if self._tool_usage_stats is not None:
full_stats = self._tool_usage_stats.get_tool_stats_dict()
tool_stats_summary = {name: {"num_calls": stats["num_times_called"]} for name, stats in full_stats.items()}
# Get available memories if ReadMemoryTool is active
available_memories = None
if self._agent.tool_is_active("read_memory") and project is not None:
available_memories = project.memories_manager.list_memories()
# Get list of languages for the active project
languages = []
if project is not None:
languages = [lang.value for lang in project.project_config.languages]
# Get file encoding for the active project
encoding = None
if project is not None:
encoding = project.project_config.encoding
return ResponseConfigOverview(
active_project=project_info,
context=context_info,
modes=modes_info,
active_tools=active_tools,
tool_stats_summary=tool_stats_summary,
registered_projects=registered_projects,
available_tools=available_tools,
available_modes=available_modes,
available_contexts=available_contexts,
available_memories=available_memories,
jetbrains_mode=self._agent.serena_config.jetbrains,
languages=languages,
encoding=encoding,
)
def _shutdown(self) -> None:
log.info("Shutting down Serena")
if self._shutdown_callback:
@@ -138,6 +457,96 @@ class SerenaDashboardAPI:
# noinspection PyUnresolvedReferences
os._exit(0)
def _get_available_languages(self) -> ResponseAvailableLanguages:
from solidlsp.ls_config import Language
def run() -> ResponseAvailableLanguages:
all_languages = [lang.value for lang in Language.iter_all(include_experimental=False)]
# Filter out already added languages for the active project
project = self._agent.get_active_project()
if project:
current_languages = [lang.value for lang in project.project_config.languages]
available_languages = [lang for lang in all_languages if lang not in current_languages]
else:
available_languages = all_languages
return ResponseAvailableLanguages(languages=sorted(available_languages))
return self._agent.execute_task(run, logged=False)
def _get_memory(self, request_get_memory: RequestGetMemory) -> ResponseGetMemory:
def run() -> ResponseGetMemory:
project = self._agent.get_active_project()
if project is None:
raise ValueError("No active project")
content = project.memories_manager.load_memory(request_get_memory.memory_name)
return ResponseGetMemory(content=content, memory_name=request_get_memory.memory_name)
return self._agent.execute_task(run, logged=False)
def _save_memory(self, request_save_memory: RequestSaveMemory) -> None:
def run() -> None:
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)
self._agent.execute_task(run, logged=True, name="SaveMemory")
def _delete_memory(self, request_delete_memory: RequestDeleteMemory) -> None:
def run() -> None:
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)
self._agent.execute_task(run, logged=True, name="DeleteMemory")
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):
raise ValueError("Serena config file not found")
with open(config_path, encoding="utf-8") as f:
content = f.read()
return ResponseGetSerenaConfig(content=content)
def _save_serena_config(self, request_save_config: RequestSaveSerenaConfig) -> None:
def run() -> None:
config_path = self._agent.serena_config.config_file_path
if config_path is None:
raise ValueError("Serena config file path not set")
with open(config_path, "w", encoding="utf-8") as f:
f.write(request_save_config.content)
self._agent.execute_task(run, logged=True, name="SaveSerenaConfig")
def _add_language(self, request_add_language: RequestAddLanguage) -> None:
from solidlsp.ls_config import Language
try:
language = Language(request_add_language.language)
except ValueError:
raise ValueError(f"Invalid language: {request_add_language.language}")
# add_language is already thread-safe
self._agent.add_language(language)
def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None:
from solidlsp.ls_config import Language
try:
language = Language(request_remove_language.language)
except ValueError:
raise ValueError(f"Invalid language: {request_remove_language.language}")
# remove_language is already thread-safe
self._agent.remove_language(language)
@staticmethod
def _find_first_free_port(start_port: int) -> int:
port = start_port
+16 -4
View File
@@ -55,6 +55,7 @@ class GuiLogViewer:
self.message_queue = queue.Queue()
self.running = False
self.log_thread = None
self.menubar: tk.Menu | None = None
self.tool_names = [] # List to store tool names for highlighting
# Define colors for different log levels
@@ -98,6 +99,17 @@ class GuiLogViewer:
"""
self.tool_names = tool_names
def set_dashboard_url(self, url: str) -> None:
def copy_url():
self.root.clipboard_clear()
self.root.clipboard_append(url)
log.info(f"Copied dashboard URL to clipboard: {url}")
if self.menubar is not None:
dashboard_menu = tk.Menu(self.menubar, tearoff=0)
dashboard_menu.add_command(label="Copy URL", command=copy_url) # type: ignore
self.menubar.add_cascade(label="Dashboard", menu=dashboard_menu)
def add_log(self, message):
"""
Add a log message to the viewer.
@@ -287,11 +299,11 @@ class GuiLogViewer:
# Create menu bar
if self.mode == "dashboard":
menubar = tk.Menu(self.root)
server_menu = tk.Menu(menubar, tearoff=0)
self.menubar = tk.Menu(self.root)
server_menu = tk.Menu(self.menubar, tearoff=0)
server_menu.add_command(label="Shutdown", command=self._shutdown_server) # type: ignore
menubar.add_cascade(label="Server", menu=server_menu)
self.root.config(menu=menubar)
self.menubar.add_cascade(label="Server", menu=server_menu)
self.root.config(menu=self.menubar)
# Configure icons
icon_16 = tk.PhotoImage(file=dashboard_path / "serena-icon-16.png")
+216
View File
@@ -0,0 +1,216 @@
import logging
import threading
from collections.abc import Iterator
from sensai.util.logging import LogTime
from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class LanguageServerFactory:
def __init__(
self,
project_root: str,
encoding: str,
ignored_patterns: list[str],
ls_timeout: float | None = None,
ls_specific_settings: dict | None = None,
log_level: int = logging.INFO,
trace_lsp_communication: bool = False,
):
self.project_root = project_root
self.encoding = encoding
self.ignored_patterns = ignored_patterns
self.ls_timeout = ls_timeout
self.ls_specific_settings = ls_specific_settings
self.log_level = log_level
self.trace_lsp_communication = trace_lsp_communication
def create_language_server(self, language: Language) -> SolidLanguageServer:
ls_config = LanguageServerConfig(
code_language=language,
ignored_paths=self.ignored_patterns,
trace_lsp_communication=self.trace_lsp_communication,
encoding=self.encoding,
)
ls_logger = LanguageServerLogger(log_level=self.log_level)
log.info(f"Creating language server instance for {self.project_root}, language={language}.")
return SolidLanguageServer.create(
ls_config,
ls_logger,
self.project_root,
timeout=self.ls_timeout,
solidlsp_settings=SolidLSPSettings(
solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME,
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
ls_specific_settings=self.ls_specific_settings or {},
),
)
class LanguageServerManager:
"""
Manages one or more language servers for a project.
"""
def __init__(
self,
language_servers: dict[Language, SolidLanguageServer],
language_server_factory: LanguageServerFactory | None = None,
) -> None:
"""
:param language_servers: a mapping from language to language server; the servers are assumed to be already started.
The first server in the iteration order is used as the default server.
All servers are assumed to serve the same project root.
:param language_server_factory: factory for language server creation; if None, dynamic (re)creation of language servers
is not supported
"""
self._language_servers = language_servers
self._language_server_factory = language_server_factory
self._default_language_server = next(iter(language_servers.values()))
self._root_path = self._default_language_server.repository_root_path
@staticmethod
def from_languages(languages: list[Language], factory: LanguageServerFactory) -> "LanguageServerManager":
"""
Creates a manager with language servers for the given languages using the given factory.
The language servers are started in parallel threads.
:param languages: the languages for which to spawn language servers
:param factory: the factory for language server creation
:return: the instance
"""
language_servers: dict[Language, SolidLanguageServer] = {}
threads = []
exceptions = {}
lock = threading.Lock()
def start_language_server(language: Language) -> None:
try:
with LogTime(f"Language server startup (language={language.value})"):
language_server = factory.create_language_server(language)
language_server.start()
if not language_server.is_running():
raise RuntimeError(f"Failed to start the language server for language {language.value}")
with lock:
language_servers[language] = language_server
except Exception as e:
log.error(f"Error starting language server for language {language.value}: {e}", exc_info=e)
with lock:
exceptions[language] = e
# start language servers in parallel threads
for language in languages:
thread = threading.Thread(target=start_language_server, args=(language,), name="StartLS:" + language.value)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
# If any server failed to start up, raise an exception and stop all started language servers
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}")
return LanguageServerManager(language_servers, factory)
def get_root_path(self) -> str:
return self._root_path
def _ensure_functional_ls(self, ls: SolidLanguageServer) -> SolidLanguageServer:
if not ls.is_running():
log.warning(f"Language server for language {ls.language} is not running; restarting ...")
ls = self.restart_language_server(ls.language)
return ls
def get_language_server(self, relative_path: str) -> SolidLanguageServer:
ls: SolidLanguageServer | None = None
if len(self._language_servers) > 1:
for candidate in self._language_servers.values():
if not candidate.is_ignored_path(relative_path, ignore_unsupported_files=True):
ls = candidate
break
if ls is None:
ls = self._default_language_server
return self._ensure_functional_ls(ls)
def _create_and_start_language_server(self, language: Language) -> SolidLanguageServer:
if self._language_server_factory is None:
raise ValueError(f"No language server factory available to create language server for {language}")
language_server = self._language_server_factory.create_language_server(language)
language_server.start()
self._language_servers[language] = language_server
return language_server
def restart_language_server(self, language: Language) -> SolidLanguageServer:
"""
Forces recreation and restart of the language server for the given language.
It is assumed that the language server for the given language is no longer running.
:param language: the language
:return: the newly created language server
"""
if language not in self._language_servers:
raise ValueError(f"No language server for language {language.value} present; cannot restart")
return self._create_and_start_language_server(language)
def add_language_server(self, language: Language) -> SolidLanguageServer:
"""
Dynamically adds a new language server for the given language.
:param language: the language
:param factory: the factory to create the language server
:return: the newly created language server
"""
if language in self._language_servers:
raise ValueError(f"Language server for language {language.value} already present")
return self._create_and_start_language_server(language)
def remove_language_server(self, language: Language, save_cache: bool = False) -> None:
"""
Removes the language server for the given language, stopping it if it is running.
:param language: the language
"""
if language not in self._language_servers:
raise ValueError(f"No language server for language {language.value} present; cannot remove")
ls = self._language_servers.pop(language)
self._stop_language_server(ls, save_cache=save_cache)
@staticmethod
def _stop_language_server(ls: SolidLanguageServer, save_cache: bool = False) -> None:
if ls.is_running():
if save_cache:
ls.save_cache()
log.info(f"Stopping language server for language {ls.language} ...")
ls.stop()
def iter_language_servers(self) -> Iterator[SolidLanguageServer]:
for ls in self._language_servers.values():
yield self._ensure_functional_ls(ls)
def stop_all(self, save_cache: bool = False) -> None:
"""
Stops all managed language servers.
:param save_cache: whether to save the cache before stopping
"""
for ls in self.iter_language_servers():
self._stop_language_server(ls, save_cache=save_cache)
def save_all_caches(self) -> None:
"""
Saves the caches of all managed language servers.
"""
for ls in self.iter_language_servers():
if ls.is_running():
ls.save_cache()
+107 -42
View File
@@ -5,16 +5,17 @@ from pathlib import Path
from typing import Any
import pathspec
from sensai.util.string import ToStringMixin
from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig, get_serena_managed_in_project_dir
from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME
from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME
from serena.ls_manager import LanguageServerFactory, LanguageServerManager
from serena.text_utils import MatchedConsecutiveLines, search_files
from serena.util.file_system import GitignoreParser, match_path
from serena.util.general import save_yaml
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.ls_config import Language
from solidlsp.ls_utils import FileUtils
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
@@ -25,21 +26,21 @@ class MemoriesManager:
self._memory_dir.mkdir(parents=True, exist_ok=True)
self._encoding = SERENA_FILE_ENCODING
def _get_memory_file_path(self, name: str) -> Path:
def get_memory_file_path(self, name: str) -> Path:
# strip all .md from the name. Models tend to get confused, sometimes passing the .md extension and sometimes not.
name = name.replace(".md", "")
filename = f"{name}.md"
return self._memory_dir / filename
def load_memory(self, name: str) -> str:
memory_file_path = self._get_memory_file_path(name)
memory_file_path = self.get_memory_file_path(name)
if not memory_file_path.exists():
return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it."
with open(memory_file_path, encoding=self._encoding) as f:
return f.read()
def save_memory(self, name: str, content: str) -> str:
memory_file_path = self._get_memory_file_path(name)
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."
@@ -48,16 +49,17 @@ class MemoriesManager:
return [f.name.replace(".md", "") for f in self._memory_dir.iterdir() if f.is_file()]
def delete_memory(self, name: str) -> str:
memory_file_path = self._get_memory_file_path(name)
memory_file_path = self.get_memory_file_path(name)
memory_file_path.unlink()
return f"Memory {name} deleted."
class Project:
class Project(ToStringMixin):
def __init__(self, project_root: str, project_config: ProjectConfig, is_newly_created: bool = False):
self.project_root = project_root
self.project_config = project_config
self.memories_manager = MemoriesManager(project_root)
self.language_server_manager: LanguageServerManager | None = None
self._is_newly_created = is_newly_created
# create .gitignore file in the project's Serena data folder if not yet present
@@ -69,7 +71,7 @@ class Project:
f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n")
# gather ignored paths from the project configuration and gitignore files
ignored_patterns = project_config.ignored_paths
ignored_patterns = list(project_config.ignored_paths)
if len(ignored_patterns) > 0:
log.info(f"Using {len(ignored_patterns)} ignored paths from the explicit project configuration.")
log.debug(f"Ignored paths: {ignored_patterns}")
@@ -90,14 +92,16 @@ class Project:
log.debug(f"Processing {len(processed_patterns)} ignored paths")
self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns)
def _tostring_includes(self) -> list[str]:
return []
def _tostring_additional_entries(self) -> dict[str, Any]:
return {"root": self.project_root, "name": self.project_name}
@property
def project_name(self) -> str:
return self.project_config.project_name
@property
def language(self) -> Language:
return self.project_config.language
@classmethod
def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project":
project_root = Path(project_root).resolve()
@@ -106,6 +110,16 @@ class Project:
project_config = ProjectConfig.load(project_root, autogenerate=autogenerate)
return Project(project_root=str(project_root), project_config=project_config)
def save_config(self) -> None:
"""
Saves the current project configuration to disk.
"""
config_path = os.path.join(self.project_root, self.project_config.rel_path_to_project_yml())
log.info("Saving updated project configuration to %s", config_path)
config_with_comments = ProjectConfig.load_commented_map(config_path)
config_with_comments.update(self.project_config.to_yaml_dict())
save_yaml(config_path, config_with_comments, preserve_comments=True)
def path_to_serena_data_folder(self) -> str:
return os.path.join(self.project_root, SERENA_MANAGED_DIR_NAME)
@@ -120,7 +134,8 @@ class Project:
msg = f"Created and activated a new project with name '{self.project_name}' at {self.project_root}. "
else:
msg = f"The project with name '{self.project_name}' at {self.project_root} is activated."
msg += f"\nProgramming language: {self.project_config.language.value}; file encoding: {self.project_config.encoding}"
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:
msg += (
@@ -172,8 +187,13 @@ class Project:
# Check file extension if it's a file
is_file = os.path.isfile(abs_path)
if is_file and ignore_non_source_files:
fn_matcher = self.language.get_source_fn_matcher()
if not fn_matcher.is_relevant_filename(abs_path):
is_file_in_supported_language = False
for language in self.project_config.languages:
fn_matcher = language.get_source_fn_matcher()
if fn_matcher.is_relevant_filename(abs_path):
is_file_in_supported_language = True
break
if not is_file_in_supported_language:
return True
# Create normalized path for consistent handling
@@ -338,43 +358,88 @@ class Project:
source_file_path=relative_file_path,
)
def create_language_server(
def create_language_server_manager(
self,
log_level: int = logging.INFO,
ls_timeout: float | None = DEFAULT_TOOL_TIMEOUT - 5,
trace_lsp_communication: bool = False,
ls_specific_settings: dict[Language, Any] | None = None,
) -> SolidLanguageServer:
) -> LanguageServerManager:
"""
Create a language server for a project. Note that you will have to start it
before performing any LS operations.
Creates the language server manager for the project, starting one language server per configured programming language.
:param project: either a path to the project root or a ProjectConfig instance.
If no project.yml is found, the default project configuration will be used.
:param log_level: the log level for the language server
:param ls_timeout: the timeout for the language server
:param trace_lsp_communication: whether to trace LSP communication
:param ls_specific_settings: optional LS specific configuration of the language server,
see docstrings in the inits of subclasses of SolidLanguageServer to see what values may be passed.
:return: the language server
:return: the language server manager, which is also stored in the project instance
"""
ls_config = LanguageServerConfig(
code_language=self.language,
ignored_paths=self._ignored_patterns,
trace_lsp_communication=trace_lsp_communication,
encoding=self.project_config.encoding,
)
ls_logger = LanguageServerLogger(log_level=log_level)
# if there is an existing instance, stop its language servers first
if self.language_server_manager is not None:
log.info("Stopping existing language server manager ...")
self.language_server_manager.stop_all()
self.language_server_manager = None
log.info(f"Creating language server instance for {self.project_root}.")
return SolidLanguageServer.create(
ls_config,
ls_logger,
self.project_root,
timeout=ls_timeout,
solidlsp_settings=SolidLSPSettings(
solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME,
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
ls_specific_settings=ls_specific_settings or {},
),
log.info(f"Creating language server manager for {self.project_root}")
factory = LanguageServerFactory(
project_root=self.project_root,
encoding=self.project_config.encoding,
ignored_patterns=self._ignored_patterns,
ls_timeout=ls_timeout,
ls_specific_settings=ls_specific_settings,
log_level=log_level,
trace_lsp_communication=trace_lsp_communication,
)
self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory)
return self.language_server_manager
def add_language(self, language: Language) -> None:
"""
Adds a new programming language to the project configuration, starting the corresponding
language server instance if the LS manager is active.
The project configuration is saved to disk after adding the language.
:param language: the programming language to add
"""
if language in self.project_config.languages:
log.info(f"Language {language.value} is already present in the project configuration.")
return
# start the language server (if the LS manager is active)
if self.language_server_manager is None:
log.info("Language server manager is not active; skipping language server startup for the new language.")
else:
log.info("Adding and starting the language server for new language %s ...", language.value)
self.language_server_manager.add_language_server(language)
# update the project configuration
self.project_config.languages.append(language)
self.save_config()
def remove_language(self, language: Language) -> None:
"""
Removes a programming language from the project configuration, stopping the corresponding
language server instance if the LS manager is active.
The project configuration is saved to disk after removing the language.
:param language: the programming language to remove
"""
if language not in self.project_config.languages:
log.info(f"Language {language.value} is not present in the project configuration.")
return
# update the project configuration
self.project_config.languages.remove(language)
self.save_config()
# stop the language server (if the LS manager is active)
if self.language_server_manager is None:
log.info("Language server manager is not active; skipping language server shutdown for the removed language.")
else:
log.info("Removing and stopping the language server for language %s ...", language.value)
self.language_server_manager.remove_language_server(language)
def shutdown(self) -> None:
if self.language_server_manager is not None:
self.language_server_manager.stop_all(save_cache=True)
self.language_server_manager = None
@@ -16,11 +16,11 @@ included_optional_tools:
tool_description_overrides:
find_symbol: |
Retrieves symbols matching `name_path` in a file.
Use `depth > 0` to include children. `name_path` can be: "foo": any symbol named "foo"; "foo/bar": "bar" within "foo"; "/foo/bar": only top-level "foo/bar"
replace_regex: |
Replaces text using regular expressions. Preferred for smaller edits where symbol-level tools aren't appropriate.
Use wildcards (.*?) to match large sections efficiently: "beginning.*?end" instead of specifying exact content.
Retrieves symbols matching `name_path_pattern` in a file.
Use `depth > 0` to include children. `name_path_pattern` can be: "foo": any symbol named "foo"; "foo/bar": "bar" within "foo"; "/foo/bar": only top-level "foo/bar"
replace_content: |
Replaces content in files. Preferred for smaller edits where symbol-level tools aren't appropriate.
Use mode "regex" with wildcards (.*?) to match large sections efficiently: "beginning.*?end" instead of specifying exact content.
Essential for multi-line replacements.
search_for_pattern: |
Flexible pattern search across codebase. Prefer symbolic operations when possible.
@@ -21,7 +21,7 @@ excluded_tools:
- read_file
- execute_shell_command
- prepare_for_new_conversation
- replace_regex
- replace_content
tool_description_overrides: {}
@@ -8,4 +8,11 @@ included_optional_tools: []
# mapping of tool names to an override of their descriptions (the default description is the docstring of the Tool's apply method).
# Sometimes, tool descriptions are too long (e.g., for ChatGPT), or users may want to override them for another reason.
tool_description_overrides: {}
tool_description_overrides: {}
# whether to assume that Serena shall only work on a single project in this context (provided that a project is given
# when Serena is started).
# If set to true and a project is provided at startup, the set of tools is limited to those required by the project's
# concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal.
# The `activate_project` tool will, therefore, be disabled in this case, as project switching is not allowed.
single_project: false
@@ -21,7 +21,14 @@ excluded_tools:
- read_file
- execute_shell_command
- prepare_for_new_conversation
- replace_regex
- replace_content
tool_description_overrides: {}
# whether to assume that Serena shall only work on a single project in this context (provided that a project is given
# when Serena is started).
# If set to true and a project is provided at startup, the set of tools is limited to those required by the project's
# concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal.
# Tools explicitly disabled by the project will not be available at all.
# The `activate_project` tool is always disabled in this case, as project switching cannot be allowed.
single_project: true
+8 -75
View File
@@ -8,14 +8,12 @@ prompt: |
When writing new code, think about where it belongs best. Don't generate new files if you don't plan on actually
integrating them into the codebase, instead use the editing tools to insert the code directly into the existing files in that case.
You have two main approaches for editing code - editing by regex and editing by symbol.
You have two main approaches for editing code - editing at the symbol level and more file-based editing.
The symbol-based approach is appropriate if you need to adjust an entire symbol, e.g. a method, a class, a function, etc.
But it is not appropriate if you need to adjust just a few lines of code within a symbol, for that you should
use the regex-based approach that is described below.
But it is not appropriate if you need to adjust just a few lines of code within a symbol.
Let us first discuss the symbol-based approach.
Symbols are identified by their name path and relative file path, see the description of the `find_symbol` tool for more details
on how the `name_path` matches symbols.
Symbols are identified by their name path and relative file path (description of the `find_symbol` tool).
You can get information about available symbols by using the `get_symbols_overview` tool for finding top-level symbols in a file,
or by using `find_symbol` if you already know the symbol's name path. You generally try to read as little code as possible
while still solving your task, meaning you only read the bodies when you need to, and after you have found the symbol you want to edit.
@@ -34,77 +32,12 @@ prompt: |
You will generally be able to use the info from the snippets and the regex-based approach to adjust the references as well.
You can assume that all symbol editing tools are reliable, so you don't need to verify the results if the tool returns without error.
{% if 'replace_regex' in available_tools %}
Let us discuss the regex-based approach.
The regex-based approach is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation.
This is the case if you need to adjust just a few lines of code within a method, or a chunk that is much smaller than a whole symbol.
You use other tools to find the relevant content and
then use your knowledge of the codebase to write the regex, if you haven't collected enough information of this content yet.
{% if 'replace_content' in available_tools %}
The `replace_content` tool allows you to perform regex-based replacements within files.
This is your primary tool for editing code whenever replacing or deleting a whole symbol would be a more expensive operation,
e.g. if you need to adjust just a few lines of code within a method.
You are extremely good at regex, so you never need to check whether the replacement produced the correct result.
In particular, you know what to escape and what not to escape, and you know how to use wildcards.
Also, the regex tool never adds any indentation (contrary to the symbolic editing tools), so you have to take care to add the correct indentation
when using it to insert code.
Moreover, the replacement tool will fail if it can't perform the desired replacement, and this is all the feedback you need.
Your overall goal for replacement operations is to use relatively short regexes, since I want you to minimize the number
of output tokens. For replacements of larger chunks of code, this means you intelligently make use of wildcards for the middle part
and of characteristic snippets for the before/after parts that uniquely identify the chunk.
For small replacements, up to a single line, you follow the following rules:
1. If the snippet to be replaced is likely to be unique within the file, you perform the replacement by directly using the escaped version of the
original.
2. If the snippet is probably not unique, and you want to replace all occurrences, you use the `allow_multiple_occurrences` flag.
3. If the snippet is not unique, and you want to replace a specific occurrence, you make use of the code surrounding the snippet
to extend the regex with content before/after such that the regex will have exactly one match.
4. You generally assume that a snippet is unique, knowing that the tool will return an error on multiple matches. You only read more file content
(for crafvarting a more specific regex) if such a failure unexpectedly occurs.
Examples:
1 Small replacement
You have read code like
```python
...
x = linear(x)
x = relu(x)
return x
...
```
and you want to replace `x = relu(x)` with `x = gelu(x)`.
You first try `replace_regex()` with the regex `x = relu\(x\)` and the replacement `x = gelu(x)`.
If this fails due to multiple matches, you will try `(linear\(x\)\s*)x = relu\(x\)(\s*return)` with the replacement `\1x = gelu(x)\2`.
2 Larger replacement
You have read code like
```python
def my_func():
...
# a comment before the snippet
x = add_fifteen(x)
# beginning of long section within my_func
....
# end of long section
call_subroutine(z)
call_second_subroutine(z)
```
and you want to replace the code starting with `x = add_fifteen(x)` until (including) `call_subroutine(z)`, but not `call_second_subroutine(z)`.
Initially, you assume that the the beginning and end of the chunk uniquely determine it within the file.
Therefore, you perform the replacement by using the regex `x = add_fifteen\(x\)\s*.*?call_subroutine\(z\)`
and the replacement being the new code you want to insert.
If this fails due to multiple matches, you will try to extend the regex with the content before/after the snippet and match groups.
The matching regex becomes:
`(before the snippet\s*)x = add_fifteen\(x\)\s*.*?call_subroutine\(z\)`
and the replacement includes the group as (schematically):
`\1<new_code>`
Generally, I remind you that you rely on the regex tool with providing you the correct feedback, no need for more verification!
IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE LONG REGEXES WITHOUT USING WILDCARDS INSTEAD!
In particular, you know how to use wildcards effectively in order to avoid specifying the full original text to be replaced!
{% endif %}
excluded_tools:
- replace_lines
@@ -12,4 +12,4 @@ excluded_tools:
- replace_lines
- insert_at_line
- execute_shell_command
- replace_regex
- replace_content
@@ -64,3 +64,5 @@ prompts:
{% for prompt in mode_system_prompts %}
- {{ prompt }}
{% endfor %}
You have hereby read the 'Serena Instructions Manual' and do not need to read it again.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+288 -261
View File
@@ -8,271 +8,158 @@
<link rel="icon" type="image/png" sizes="16x16" href="serena-icon-16.png">
<link rel="icon" type="image/png" sizes="32x32" href="serena-icon-32.png">
<link rel="icon" type="image/png" sizes="48x48" href="serena-icon-48.png">
<link rel="stylesheet" href="dashboard.css">
<script src="jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
<script src="dashboard.js"></script>
<style>
:root {
/* Light theme variables */
--bg-primary: #f5f5f5;
--bg-secondary: #ffffff;
--text-primary: #000000;
--text-secondary: #333333;
--text-muted: #666666;
--border-color: #ddd;
--btn-primary: #eaa45d;
--btn-hover: #dca662;
--btn-disabled: #6c757d;
--shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--tool-highlight: #ffff00;
--tool-highlight-text: #000000;
--log-debug: #808080;
--log-info: #000000;
--log-warning: #FF8C00;
--log-error: #FF0000;
--stats-header: #f8f9fa;
}
[data-theme="dark"] {
/* Dark theme variables */
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #e0e0e0;
--text-muted: #b0b0b0;
--border-color: #444;
--btn-primary: #eaa45d;
--btn-hover: #dca662;
--btn-disabled: #6c757d;
--shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
--tool-highlight: #ffd700;
--tool-highlight-text: #000000;
--log-debug: #808080;
--log-info: #ffffff;
--log-warning: #FF8C00;
--log-error: #FF0000;
--stats-header: #3a3a3a;
}
body {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
margin: 0;
padding: 20px;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.log-container {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 5px;
height: 600px;
overflow-y: auto;
overflow-x: auto;
padding: 10px;
white-space: pre-wrap;
font-size: 12px;
line-height: 1.4;
color: var(--text-primary);
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
.controls {
margin-bottom: 10px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.logo {
margin-bottom: 10px;
text-align: center;
}
.btn {
background-color: var(--btn-primary);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
.btn:hover {
background-color: var(--btn-hover);
}
.btn:disabled {
background-color: var(--btn-disabled);
cursor: not-allowed;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 5px;
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.theme-toggle:hover {
background-color: var(--border-color);
}
.theme-toggle .icon {
font-size: 16px;
}
.log-debug {
color: var(--log-debug);
}
.log-info {
color: var(--log-info);
}
.log-warning {
color: var(--log-warning);
}
.log-error {
color: var(--log-error);
}
.log-default {
color: var(--log-info);
}
/* Tool name highlighting */
.tool-name {
background-color: var(--tool-highlight);
color: var(--tool-highlight-text);
font-weight: bold;
}
.loading {
text-align: center;
color: var(--text-muted);
font-style: italic;
}
.error-message {
color: var(--log-error);
text-align: center;
margin: 10px 0;
}
.charts-container {
display: flex;
flex-wrap: wrap;
gap: 15px;
justify-content: space-between;
max-width: 1400px;
margin: 0 auto;
}
.chart-group {
flex: 1;
min-width: 280px;
max-width: 320px;
text-align: center;
}
.chart-wide {
flex: 0 0 100%;
min-width: 100%;
margin-top: 10px;
}
.chart-group h3 {
margin: 0 0 10px 0;
color: var(--text-secondary);
}
.stats-summary {
margin: 0 auto;
border-collapse: collapse;
background: var(--bg-secondary);
border-radius: 5px;
overflow: hidden;
box-shadow: var(--shadow);
transition: background-color 0.3s ease, box-shadow 0.3s ease;
}
.stats-summary th,
.stats-summary td {
padding: 10px 20px;
text-align: left;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
transition: border-color 0.3s ease, color 0.3s ease;
}
.stats-summary th {
background-color: var(--stats-header);
font-weight: bold;
transition: background-color 0.3s ease;
}
.stats-summary tr:last-child td {
border-bottom: none;
}
@media (max-width: 768px) {
.charts-container {
flex-direction: column;
}
.chart-group,
.chart-wide {
min-width: auto;
max-width: none;
}
.controls {
flex-direction: column;
gap: 5px;
}
}
</style>
</head>
<body>
<div class="header">
<img id="serena-logo" src="serena-logs.png" alt="Serena" style="max-width: 400px; height: auto;">
</div>
<div id="frame">
<header class="header">
<div class="header-left">
<div class="controls">
<button id="load-logs" class="btn">Reload Log</button>
<button id="shutdown" class="btn">Shutdown Server</button>
<button id="toggle-stats" class="btn">Show Stats</button>
<div id="theme-toggle" class="theme-toggle" title="Toggle theme">
<span class="icon" id="theme-icon">🌙</span>
<span id="theme-text">Dark</span>
<div class="logo-container">
<img id="serena-logo" src="serena-logs.png" alt="Serena">
</div>
<!-- Platinum Sponsors in Header -->
<div id="platinum-sponsors" class="header-sponsor">
</div>
</div>
<div id="error-container"></div>
<div id="log-container" class="log-container"></div>
<nav class="header-nav">
<button id="theme-toggle" class="theme-toggle">
<span id="theme-icon" style="height: 21px">🌙</span>
<span id="theme-text">Dark</span>
</button>
<button id="menu-toggle" class="menu-button">
<span>☰</span>
<span>Menu</span>
</button>
<div id="menu-dropdown" class="menu-dropdown" style="display:none">
<a href="#" data-page="overview" class="active">Overview</a>
<a href="#" data-page="logs">Logs</a>
<a href="#" data-page="stats">Advanced Stats</a>
<hr>
<a href="#" id="menu-shutdown">Shutdown Server</a>
</div>
</nav>
</header>
<div id="stats-section" style="display:none; margin-top:20px;">
<div style="text-align:center; margin-bottom:20px;">
<div class="main">
<!-- Overview Page (Landing) -->
<div id="page-overview" class="page-view">
<div class="overview-container">
<div class="overview-left">
<section class="config-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="margin: 0;">Current Configuration</h2>
</div>
<div id="config-display">
<div class="loading">Loading configuration...</div>
</div>
</section>
<section class="basic-stats-section">
<h2>Tool Usage</h2>
<div id="basic-stats-display">
<div class="loading">Loading stats...</div>
</div>
</section>
<section class="executions-section">
<h2>Executions Queue</h2>
<div id="active-executions-display">
<div class="loading">Loading executions...</div>
</div>
</section>
<section class="executions-section">
<h2>Last Execution</h2>
<div id="last-execution-display">
<div class="loading">Loading...</div>
</div>
</section>
<section class="executions-section" style="display:none">
<h2>Cancelled Executions</h2>
<div id="cancelled-executions-display">
<div class="no-stats-message">No cancelled executions.</div>
</div>
</section>
</div>
<div class="overview-right">
<section class="projects-section">
<h2 class="collapsible-header" id="projects-header">
<span>Registered Projects</span>
<span class="toggle-icon">▼</span>
</h2>
<div id="projects-display" class="collapsible-content" style="display:none">
<div class="loading">Loading projects...</div>
</div>
</section>
<section class="projects-section">
<h2 class="collapsible-header" id="available-tools-header">
<span>Available Tools (Disabled)</span>
<span class="toggle-icon">▼</span>
</h2>
<div id="available-tools-display" class="collapsible-content" style="display:none">
<div class="loading">Loading tools...</div>
</div>
</section>
<section class="projects-section">
<h2 class="collapsible-header" id="available-modes-header">
<span>Available Modes</span>
<span class="toggle-icon">▼</span>
</h2>
<div id="available-modes-display" class="collapsible-content" style="display:none">
<div class="loading">Loading modes...</div>
</div>
</section>
<section class="projects-section">
<h2 class="collapsible-header" id="available-contexts-header">
<span>Available Contexts</span>
<span class="toggle-icon">▼</span>
</h2>
<div id="available-contexts-display" class="collapsible-content" style="display:none">
<div class="loading">Loading contexts...</div>
</div>
</section>
<!-- Gold Sponsors -->
<section id="gold-sponsors" class="gold-sponsors-section">
</section>
</div>
</div>
</div>
<!-- Logs Page -->
<div id="page-logs" class="page-view" style="display:none">
<div id="error-container"></div>
<div style="position: relative;">
<button id="copy-logs-btn" class="copy-logs-btn" title="Copy logs">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-logs-text">copy logs</span>
</button>
<div id="log-container" class="log-container"></div>
</div>
</div>
<!-- Advanced Stats Page -->
<div id="page-stats" class="page-view" style="display:none">
<div class="controls">
<button id="refresh-stats" class="btn">Refresh Stats</button>
<button id="clear-stats" class="btn">Clear Stats</button>
</div>
@@ -280,10 +167,9 @@
<div id="stats-summary" style="margin-bottom:20px; text-align:center;"></div>
<div id="estimator-name" style="text-align:center; margin-bottom:10px;"></div>
<div id="no-stats-message" style="text-align:center; color:var(--text-muted); font-style:italic; display:none;">
No tool stats collected. Have you enabled tool stats collection in the configuration?
No tool stats collected yet.
</div>
<div class="charts-container">
<div class="chart-group">
<h3>Tool Calls</h3>
@@ -304,11 +190,152 @@
</div>
</div>
<script>
$(document).ready(function () {
const dashboard = new Dashboard();
});
</script>
<!-- Add Language Modal -->
<div id="add-language-modal" class="modal" style="display:none">
<div class="modal-content">
<span class="modal-close">&times;</span>
<h3>Add Language</h3>
<p id="modal-project-info" style="margin: 15px 0; color: var(--text-primary); line-height: 1.5;">
Adding a language to serena config of project <strong id="modal-project-name"></strong>.
</p>
<p style="margin: 15px 0; color: var(--text-muted); font-size: 13px; line-height: 1.5;">
Note that this may download some dependencies needed for the language server and then start it,
it may take a few seconds before the LS is responsive.
</p>
<div style="margin: 20px 0;">
<label for="modal-language-select" style="display: block; margin-bottom: 8px; font-weight: 500;">Select
Language:</label>
<select id="modal-language-select" class="language-select"
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;">
</select>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="modal-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="modal-add-btn" class="btn" style="padding: 8px 16px;">Add Language</button>
</div>
</div>
</div>
<!-- Remove Language Confirmation Modal -->
<div id="remove-language-modal" class="modal" style="display:none">
<div class="modal-content">
<span class="modal-close modal-close-remove">&times;</span>
<p style="margin: 20px 0; color: var(--text-primary); line-height: 1.5; font-size: 15px;">
Remove language <strong id="remove-language-name"></strong> from configuration?
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="remove-modal-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="remove-modal-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
</div>
</div>
</div>
<!-- Edit Memory Modal -->
<div id="edit-memory-modal" class="modal" style="display:none">
<div class="modal-content modal-content-large">
<span class="modal-close modal-close-edit-memory">&times;</span>
<h3 style="margin-bottom: 20px;">Memory: <span id="edit-memory-name"></span></h3>
<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;">
<button id="edit-memory-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="edit-memory-save-btn" class="btn" style="padding: 8px 16px;">Save</button>
</div>
</div>
</div>
<!-- Delete Memory Confirmation Modal -->
<div id="delete-memory-modal" class="modal" style="display:none">
<div class="modal-content">
<span class="modal-close modal-close-delete-memory">&times;</span>
<p style="margin: 20px 0; color: var(--text-primary); line-height: 1.5; font-size: 15px;">
Delete memory <strong id="delete-memory-name"></strong>?
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="delete-memory-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="delete-memory-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
</div>
</div>
</div>
<!-- Create Memory Modal -->
<div id="create-memory-modal" class="modal" style="display:none">
<div class="modal-content">
<span class="modal-close modal-close-create-memory">&times;</span>
<p id="create-memory-project-info" style="margin: 15px 0; color: var(--text-primary); line-height: 1.5;">
Create a new memory for project <strong id="create-memory-project-name"></strong>.
</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").
</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"
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;">
<button id="create-memory-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="create-memory-create-btn" class="btn" style="padding: 8px 16px;">Create</button>
</div>
</div>
</div>
<!-- Cancel Execution Confirmation Modal -->
<div id="cancel-execution-modal" class="modal" style="display:none">
<div class="modal-content">
<span class="modal-close modal-close-cancel-execution">&times;</span>
<p style="margin: 20px 0; color: var(--text-primary); line-height: 1.5; font-size: 15px;">
Are you sure? The execution will continue running until timeout, it will simply no longer be in the queue.
Abandoning a running execution is only advised as a measure for unblocking Serena.
</p>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<button id="cancel-execution-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="cancel-execution-ok-btn" class="btn" style="padding: 8px 16px;">OK</button>
</div>
</div>
</div>
<!-- Edit Serena Config Modal -->
<div id="edit-serena-config-modal" class="modal" style="display:none">
<div class="modal-content modal-content-large">
<span class="modal-close modal-close-edit-serena-config">&times;</span>
<h3 style="margin-bottom: 10px;">Global Serena Configuration</h3>
<p style="margin: 10px 0 20px 0; color: var(--text-muted); font-size: 13px; line-height: 1.5;">
Note: Changes to the configuration will only take effect after Serena is restarted.
</p>
<textarea id="edit-serena-config-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;">
<button id="edit-serena-config-cancel-btn" class="btn"
style="background: var(--bg-secondary); color: var(--text-primary); padding: 8px 16px;">Cancel
</button>
<button id="edit-serena-config-save-btn" class="btn" style="padding: 8px 16px;">Save</button>
</div>
</div>
</div>
</div> <!-- End of Main -->
<script>
$(document).ready(function () {
const dashboard = new Dashboard();
});
</script>
</div> <!-- End of Frame -->
</body>
</html>
</html>
Binary file not shown.
+16 -5
View File
@@ -1,9 +1,19 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran go
# haskell java julia kotlin lua markdown
# nix perl php python python_jedi r
# rego ruby ruby_solargraph rust scala swift
# terraform typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: python
# - csharp: Requires the presence of a .sln file in the project folder.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages: ["python"]
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
@@ -12,6 +22,7 @@ encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
@@ -4,7 +4,7 @@ gui_log_window: False
# If you want to see the logs in a web browser, use the `web_dashboard` option instead.
# Limitations: doesn't seem to work with the community version of Claude Desktop for Linux
# Might also cause problems with some MCP clients - if you have any issues, try disabling this
#
# Being able to inspect logs is useful both for troubleshooting and for monitoring the tool calls,
# especially when using the agno playground, since the tool calls are not always shown,
# and the input params are never shown in the agno UI.
@@ -61,16 +61,14 @@ default_max_tool_answer_chars: 150000
# Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default
# through the global configuration.
record_tool_usage_stats: False
# whether to record tool usage statistics, they will be shown in the web dashboard if recording is active.
token_count_estimator: TIKTOKEN_GPT4O
# Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics.
token_count_estimator: CHAR_COUNT
# the name of the token count estimator to use for tool usage statistics.
# See the `RegisteredTokenCountEstimator` enum for available options.
#
# Note: some token estimators (like tiktoken) may require downloading data files
# on the first run, which can take some time and require internet access. Others, like the Anthropic ones, may require an API key
# and rate limits may apply.
# By default, a very naive character count estimator is used, which simply counts the number of characters.
# You can configure this to TIKTOKEN_GPT4 to use a local tiktoken-based estimator for GPT-4 (will download tiktoken
# data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to
# estimate the token count using the Claude Sonnet 4 tokenizer.
# MANAGED BY SERENA, KEEP AT THE BOTTOM OF THE YAML AND DON'T EDIT WITHOUT NEED
+104 -61
View File
@@ -4,7 +4,7 @@ import os
from abc import ABC, abstractmethod
from collections.abc import Iterator, Sequence
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Any, Self, Union
from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, Union
from sensai.util.string import ToStringMixin
@@ -12,12 +12,14 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol
from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation
from .ls_manager import LanguageServerManager
from .project import Project
if TYPE_CHECKING:
from .agent import SerenaAgent
log = logging.getLogger(__name__)
NAME_PATH_SEP = "/"
@dataclass
@@ -77,7 +79,7 @@ class PositionInFile:
return Position(line=self.line, character=self.col)
class Symbol(ABC):
class Symbol(ToStringMixin, ABC):
@abstractmethod
def get_body_start_position(self) -> PositionInFile | None:
pass
@@ -112,45 +114,65 @@ class Symbol(ABC):
"""
class LanguageServerSymbol(Symbol, ToStringMixin):
_NAME_PATH_SEP = "/"
@staticmethod
def match_name_path(
name_path: str,
symbol_name_path_parts: list[str],
substring_matching: bool,
) -> bool:
class NamePathMatcher(ToStringMixin):
def __init__(self, name_path_expr: str, substring_matching: bool) -> None:
"""
Checks if a given `name_path` matches a symbol's qualified name parts.
See docstring of `Symbol.find` for more details.
:param name_path_expr: the name path expression to match against
:param substring_matching: whether to use substring matching for the last segment
"""
assert name_path, "name_path must not be empty"
assert symbol_name_path_parts, "symbol_name_path_parts must not be empty"
name_path_sep = LanguageServerSymbol._NAME_PATH_SEP
assert name_path_expr, "name_path must not be empty"
self._expr = name_path_expr
self._substring_matching = substring_matching
self._is_absolute_pattern = name_path_expr.startswith(NAME_PATH_SEP)
self._pattern_parts = name_path_expr.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP)
is_absolute_pattern = name_path.startswith(name_path_sep)
pattern_parts = name_path.lstrip(name_path_sep).rstrip(name_path_sep).split(name_path_sep)
# extract overload index "[idx]" if present at end of last part
self._overload_idx: int | None = None
last_part = self._pattern_parts[-1]
if last_part.endswith("]") and "[" in last_part:
bracket_idx = last_part.rfind("[")
index_part = last_part[bracket_idx + 1 : -1]
if index_part.isdigit():
self._pattern_parts[-1] = last_part[:bracket_idx]
self._overload_idx = int(index_part)
def _tostring_includes(self) -> list[str]:
return ["_expr"]
def matches_ls_symbol(self, symbol: "LanguageServerSymbol") -> bool:
return self.matches_components(symbol.get_name_path_parts(), symbol.overload_idx)
def matches_components(self, symbol_name_path_parts: list[str], overload_idx: int | None) -> bool:
# filtering based on ancestors
if len(pattern_parts) > len(symbol_name_path_parts):
if len(self._pattern_parts) > len(symbol_name_path_parts):
# can't possibly match if pattern has more parts than symbol
return False
if is_absolute_pattern and len(pattern_parts) != len(symbol_name_path_parts):
if self._is_absolute_pattern and len(self._pattern_parts) != len(symbol_name_path_parts):
# for absolute patterns, the number of parts must match exactly
return False
if symbol_name_path_parts[-len(pattern_parts) : -1] != pattern_parts[:-1]:
if symbol_name_path_parts[-len(self._pattern_parts) : -1] != self._pattern_parts[:-1]:
# ancestors must match
return False
# matching the last part of the symbol name
name_to_match = pattern_parts[-1]
name_to_match = self._pattern_parts[-1]
symbol_name = symbol_name_path_parts[-1]
if substring_matching:
return name_to_match in symbol_name
if self._substring_matching:
if name_to_match not in symbol_name:
return False
else:
return name_to_match == symbol_name
if name_to_match != symbol_name:
return False
# check for matching overload index
if self._overload_idx is not None:
if overload_idx != self._overload_idx:
return False
return True
class LanguageServerSymbol(Symbol, ToStringMixin):
def __init__(self, symbol_root_from_ls: UnifiedSymbolInformation) -> None:
self.symbol_root = symbol_root_from_ls
@@ -172,6 +194,10 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
def symbol_kind(self) -> SymbolKind:
return self.symbol_root["kind"]
@property
def overload_idx(self) -> int | None:
return self.symbol_root.get("overload_idx")
def is_neighbouring_definition_separated_by_empty_line(self) -> bool:
return self.symbol_kind in (SymbolKind.Function, SymbolKind.Method, SymbolKind.Class, SymbolKind.Interface, SymbolKind.Struct)
@@ -255,9 +281,13 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
def get_name_path(self) -> str:
"""
Get the name path of the symbol (e.g. "class/method/inner_function").
Get the name path of the symbol, e.g. "class/method/inner_function" or
"class/method[1]" (overloaded method with identifying index).
"""
return self._NAME_PATH_SEP.join(self.get_name_path_parts())
name_path = NAME_PATH_SEP.join(self.get_name_path_parts())
if "overload_idx" in self.symbol_root:
name_path += f"[{self.symbol_root['overload_idx']}]"
return name_path
def get_name_path_parts(self) -> list[str]:
"""
@@ -320,7 +350,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`.
Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`.
:param name_path: the name path to match against
:param name_path: the name path expression to match against
:param substring_matching: whether to use substring matching (as opposed to exact matching)
of the last segment of `name_path` against the symbol name.
:param include_kinds: an optional sequence of ints representing the LSP symbol kind.
@@ -328,17 +358,14 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
:param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result.
"""
result = []
name_path_matcher = NamePathMatcher(name_path, substring_matching)
def should_include(s: "LanguageServerSymbol") -> bool:
if include_kinds is not None and s.symbol_kind not in include_kinds:
return False
if exclude_kinds is not None and s.symbol_kind in exclude_kinds:
return False
return LanguageServerSymbol.match_name_path(
name_path=name_path,
symbol_name_path_parts=s.get_name_path_parts(),
substring_matching=substring_matching,
)
return name_path_matcher.matches_ls_symbol(s)
def traverse(s: "LanguageServerSymbol") -> None:
if should_include(s):
@@ -441,29 +468,29 @@ class ReferenceInLanguageServerSymbol(ToStringMixin):
class LanguageServerSymbolRetriever:
def __init__(self, lang_server: SolidLanguageServer, agent: Union["SerenaAgent", None] = None) -> None:
def __init__(self, ls: SolidLanguageServer | LanguageServerManager, agent: Union["SerenaAgent", None] = None) -> None:
"""
:param lang_server: the language server to use for symbol retrieval as well as editing operations.
:param ls: the language server or language server manager to use for symbol retrieval and editing operations.
:param agent: the agent to use (only needed for marking files as modified). You can pass None if you don't
need an agent to be aware of file modifications performed by the symbol manager.
"""
self._lang_server = lang_server
if isinstance(ls, SolidLanguageServer):
ls_manager = LanguageServerManager({ls.language: ls})
else:
ls_manager = ls
assert isinstance(ls_manager, LanguageServerManager)
self._ls_manager: LanguageServerManager = ls_manager
self.agent = agent
def set_language_server(self, lang_server: SolidLanguageServer) -> None:
"""
Set the language server to use for symbol retrieval and editing operations.
This is useful if you want to change the language server after initializing the SymbolManager.
"""
self._lang_server = lang_server
def get_root_path(self) -> str:
return self._ls_manager.get_root_path()
def get_language_server(self) -> SolidLanguageServer:
return self._lang_server
def get_language_server(self, relative_path: str) -> SolidLanguageServer:
return self._ls_manager.get_language_server(relative_path)
def find_by_name(
self,
name_path: str,
include_body: bool = False,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
substring_matching: bool = False,
@@ -475,25 +502,22 @@ class LanguageServerSymbolRetriever:
to symbols within a specific file or directory.
"""
symbols: list[LanguageServerSymbol] = []
symbol_roots = self._lang_server.request_full_symbol_tree(within_relative_path=within_relative_path, include_body=include_body)
for root in symbol_roots:
symbols.extend(
LanguageServerSymbol(root).find(
name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
for lang_server in self._ls_manager.iter_language_servers():
symbol_roots = lang_server.request_full_symbol_tree(within_relative_path=within_relative_path)
for root in symbol_roots:
symbols.extend(
LanguageServerSymbol(root).find(
name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
)
)
)
return symbols
def get_document_symbols(self, relative_path: str) -> list[LanguageServerSymbol]:
symbol_dicts, _roots = self._lang_server.request_document_symbols(relative_path, include_body=False)
symbols = [LanguageServerSymbol(s) for s in symbol_dicts]
return symbols
def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None:
if location.relative_path is None:
return None
symbol_dicts, _roots = self._lang_server.request_document_symbols(location.relative_path, include_body=False)
for symbol_dict in symbol_dicts:
lang_server = self.get_language_server(location.relative_path)
document_symbols = lang_server.request_document_symbols(location.relative_path)
for symbol_dict in document_symbols.iter_symbols():
symbol = LanguageServerSymbol(symbol_dict)
if symbol.location == location:
return symbol
@@ -561,7 +585,8 @@ class LanguageServerSymbolRetriever:
assert symbol_location.relative_path is not None
assert symbol_location.line is not None
assert symbol_location.column is not None
references = self._lang_server.request_referencing_symbols(
lang_server = self.get_language_server(symbol_location.relative_path)
references = lang_server.request_referencing_symbols(
relative_file_path=symbol_location.relative_path,
line=symbol_location.line,
column=symbol_location.column,
@@ -589,7 +614,8 @@ class LanguageServerSymbolRetriever:
return cls(name_path=symbol.get_name_path(), kind=int(symbol.symbol_kind))
def get_symbol_overview(self, relative_path: str) -> dict[str, list[SymbolOverviewElement]]:
path_to_unified_symbols = self._lang_server.request_overview(relative_path)
lang_server = self.get_language_server(relative_path)
path_to_unified_symbols = lang_server.request_overview(relative_path)
result = {}
for file_path, unified_symbols in path_to_unified_symbols.items():
# TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables
@@ -599,7 +625,15 @@ class LanguageServerSymbolRetriever:
class JetBrainsSymbol(Symbol):
def __init__(self, symbol_dict: dict, project: Project) -> None:
class SymbolDict(TypedDict):
name_path: str
relative_path: str
type: str
text_range: NotRequired[dict]
body: NotRequired[str]
children: NotRequired[list["JetBrainsSymbol.SymbolDict"]]
def __init__(self, symbol_dict: SymbolDict, project: Project) -> None:
"""
:param symbol_dict: dictionary as returned by the JetBrains plugin client.
"""
@@ -609,6 +643,15 @@ class JetBrainsSymbol(Symbol):
self._cached_body_start_position: PositionInFile | None = None
self._cached_body_end_position: PositionInFile | None = None
def _tostring_includes(self) -> list[str]:
return []
def _tostring_additional_entries(self) -> dict[str, Any]:
return dict(name_path=self.get_name_path(), relative_path=self.get_relative_path(), type=self._dict["type"])
def get_name_path(self) -> str:
return self._dict["name_path"]
def get_relative_path(self) -> str:
return self._dict["relative_path"]
+218
View File
@@ -0,0 +1,218 @@
import concurrent.futures
import threading
import time
from collections.abc import Callable
from concurrent.futures import Future
from dataclasses import dataclass
from threading import Thread
from typing import Generic, TypeVar
from sensai.util import logging
from sensai.util.logging import LogTime
from sensai.util.string import ToStringMixin
log = logging.getLogger(__name__)
T = TypeVar("T")
class TaskExecutor:
def __init__(self, name: str):
self._task_executor_lock = threading.Lock()
self._task_executor_queue: list[TaskExecutor.Task] = []
self._task_executor_thread = Thread(target=self._process_task_queue, name=name, daemon=True)
self._task_executor_thread.start()
self._task_executor_task_index = 1
self._task_executor_current_task: TaskExecutor.Task | None = None
self._task_executor_last_executed_task_info: TaskExecutor.TaskInfo | None = None
class Task(ToStringMixin, Generic[T]):
def __init__(self, function: Callable[[], T], name: str, logged: bool = True, timeout: float | None = None):
"""
:param function: the function representing the task to execute
:param name: the name of the task
:param logged: whether to log management of the task; if False, only errors will be logged
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
"""
self.name = name
self.future: concurrent.futures.Future = concurrent.futures.Future()
self.logged = logged
self.timeout = timeout
self._function = function
def _tostring_includes(self) -> list[str]:
return ["name"]
def start(self) -> None:
"""
Executes the task in a separate thread, setting the result or exception on the future.
"""
def run_task() -> None:
try:
if self.future.done():
if self.logged:
log.info(f"Task {self.name} was already completed/cancelled; skipping execution")
return
with LogTime(self.name, logger=log, enabled=self.logged):
result = self._function()
if not self.future.done():
self.future.set_result(result)
except Exception as e:
if not self.future.done():
log.error(f"Error during execution of {self.name}: {e}", exc_info=e)
self.future.set_exception(e)
thread = Thread(target=run_task, name=self.name)
thread.start()
def is_done(self) -> bool:
"""
:return: whether the task has completed (either successfully, with failure, or via cancellation)
"""
return self.future.done()
def result(self, timeout: float | None = None) -> T:
"""
Blocks until the task is done or the timeout is reached, and returns the result.
If an exception occurred during task execution, it is raised here.
If the timeout is reached, a TimeoutError is raised (but the task is not cancelled).
If the task is cancelled, a CancelledError is raised.
:param timeout: the maximum time to wait in seconds; if None, use the task's own timeout
(which may be None to wait indefinitely)
:return: True if the task is done, False if the timeout was reached
"""
return self.future.result(timeout=timeout)
def cancel(self) -> None:
"""
Cancels the task. If it has not yet started, it will not be executed.
If it has already started, its future will be marked as cancelled and will raise a CancelledError
when its result is requested.
"""
self.future.cancel()
def wait_until_done(self, timeout: float | None = None) -> None:
"""
Waits until the task is done or the timeout is reached.
The task is done if it either completed successfully, failed with an exception, or was cancelled.
:param timeout: the maximum time to wait in seconds; if None, use the task's own timeout
(which may be None to wait indefinitely)
"""
try:
self.future.result(timeout=timeout)
except:
pass
def _process_task_queue(self) -> None:
while True:
# obtain task from the queue
task: TaskExecutor.Task | None = None
with self._task_executor_lock:
if len(self._task_executor_queue) > 0:
task = self._task_executor_queue.pop(0)
if task is None:
time.sleep(0.1)
continue
# start task execution asynchronously
with self._task_executor_lock:
self._task_executor_current_task = task
if task.logged:
log.info("Starting execution of %s", task.name)
task.start()
# wait for task completion
task.wait_until_done(timeout=task.timeout)
with self._task_executor_lock:
self._task_executor_current_task = None
if task.logged:
self._task_executor_last_executed_task_info = self.TaskInfo.from_task(task, is_running=False)
@dataclass
class TaskInfo:
name: str
is_running: bool
future: Future
"""
future for accessing the task's result
"""
task_id: int
"""
unique identifier of the task
"""
logged: bool
def finished_successfully(self) -> bool:
return self.future.done() and not self.future.cancelled() and self.future.exception() is None
@staticmethod
def from_task(task: "TaskExecutor.Task", is_running: bool) -> "TaskExecutor.TaskInfo":
return TaskExecutor.TaskInfo(name=task.name, is_running=is_running, future=task.future, task_id=id(task), logged=task.logged)
def cancel(self) -> None:
self.future.cancel()
def get_current_tasks(self) -> list[TaskInfo]:
"""
Gets the list of tasks currently running or queued for execution.
The function returns a list of thread-safe TaskInfo objects (specifically created for the caller).
:return: the list of tasks in the execution order (running task first)
"""
tasks = []
with self._task_executor_lock:
if self._task_executor_current_task is not None:
tasks.append(self.TaskInfo.from_task(self._task_executor_current_task, True))
for task in self._task_executor_queue:
if not task.is_done():
tasks.append(self.TaskInfo.from_task(task, False))
return tasks
def issue_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> Task[T]:
"""
Issue a task to the executor for asynchronous execution.
It is ensured that tasks are executed in the order they are issued, one after another.
:param task: the task to execute
:param name: the name of the task for logging purposes; if None, use the task function's name
:param logged: whether to log management of the task; if False, only errors will be logged
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
:return: the task object, through which the task's future result can be accessed
"""
with self._task_executor_lock:
if logged:
task_prefix_name = f"Task-{self._task_executor_task_index}"
self._task_executor_task_index += 1
else:
task_prefix_name = "BackgroundTask"
task_name = f"{task_prefix_name}:{name or task.__name__}"
if logged:
log.info(f"Scheduling {task_name}")
task_obj = self.Task(function=task, name=task_name, logged=logged, timeout=timeout)
self._task_executor_queue.append(task_obj)
return task_obj
def execute_task(self, task: Callable[[], T], name: str | None = None, logged: bool = True, timeout: float | None = None) -> T:
"""
Executes the given task synchronously via the agent's task executor.
This is useful for tasks that need to be executed immediately and whose results are needed right away.
:param task: the task to execute
:param name: the name of the task for logging purposes; if None, use the task function's name
:param logged: whether to log management of the task; if False, only errors will be logged
:param timeout: the maximum time to wait for task completion in seconds, or None to wait indefinitely
:return: the result of the task execution
"""
task_obj = self.issue_task(task, name=name, logged=logged, timeout=timeout)
return task_obj.result()
def get_last_executed_task(self) -> TaskInfo | None:
"""
Gets information about the last executed task.
:return: TaskInfo of the last executed task, or None if no task has been executed yet.
"""
with self._task_executor_lock:
return self._task_executor_last_executed_task_info
+59 -19
View File
@@ -12,6 +12,7 @@ import re
from collections import defaultdict
from fnmatch import fnmatch
from pathlib import Path
from typing import Literal
from serena.text_utils import search_files
from serena.tools import SUCCESS_RESULT, EditedFileContext, Tool, ToolMarkerCanEdit, ToolMarkerOptional
@@ -157,53 +158,92 @@ class FindFileTool(Tool):
return result
class ReplaceRegexTool(Tool, ToolMarkerCanEdit):
class ReplaceContentTool(Tool, ToolMarkerCanEdit):
"""
Replaces content in a file by using regular expressions.
Replaces content in a file (optionally using regular expressions).
"""
def apply(
self,
relative_path: str,
regex: str,
needle: str,
repl: str,
mode: Literal["literal", "regex"],
allow_multiple_occurrences: bool = False,
) -> str:
r"""
Replaces one or more occurrences of the given regular expression.
Replaces one or more occurrences of a given pattern in a file with new content.
This is the preferred way to replace content in a file whenever the symbol-level
tools are not appropriate.
Even large sections of code can be replaced by providing a concise regular expression of
the form "beginning.*?end-of-text-to-be-replaced".
Always try to use wildcards to avoid specifying the exact content of the code to be replaced,
especially if it spans several lines.
IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE UNNECESSARILY LONG REGEXES WITHOUT USING WILDCARDS!
VERY IMPORTANT: The "regex" mode allows very large sections of code to be replaced without fully quoting them!
Use a regex of the form "beginning.*?end-of-text-to-be-replaced" to be faster and more economical!
ALWAYS try to use wildcards to avoid specifying the exact content to be replaced,
especially if it spans several lines. Note that you cannot make mistakes, because if the regex should match
multiple occurrences while you disabled `allow_multiple_occurrences`, an error will be returned, and you can retry
with a revised regex.
Therefore, using regex mode with suitable wildcards is usually the best choice!
:param relative_path: the relative path to the file
:param regex: a Python-style regular expression, matches of which will be replaced.
Dot matches all characters, multi-line matching is enabled.
:param repl: the string to replace the matched content with, which may contain
backreferences like \1, \2, etc.
IMPORTANT: Make sure to escape special characters appropriately!
Use "\n" to insert a newline, but use "\\n" to insert the string "\n" within a string literal.
:param needle: the string or regex pattern to search for.
If `mode` is "literal", this string will be matched exactly.
If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module,
with flags DOTALL and MULTILINE enabled).
:param repl: the replacement string (verbatim).
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).
"""
self.project.validate_relative_path(relative_path, require_not_ignored=True)
return self.replace_content(
relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True
)
def replace_content(
self,
relative_path: str,
needle: str,
repl: str,
mode: Literal["literal", "regex"],
allow_multiple_occurrences: bool = False,
require_not_ignored: bool = True,
) -> str:
"""
Performs the replacement, with additional options not exposed in the tool.
This function can be used internally by other tools.
"""
self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored)
with EditedFileContext(relative_path, self.agent) as context:
original_content = context.get_original_content()
if mode == "literal":
regex = re.escape(needle)
elif mode == "regex":
regex = needle
else:
raise ValueError(f"Invalid mode: '{mode}', expected 'literal' or 'regex'.")
# escape backslashes in repl
repl = repl.replace("\\", "\\\\")
# convert $!1, $!2, etc. to \1, \2, etc. for backreferences
repl = re.sub(r"\$!(\d+)", "\\\\1", repl)
# perform replacement
updated_content, n = re.subn(regex, repl, original_content, flags=re.DOTALL | re.MULTILINE)
if n == 0:
return f"Error: No matches found for regex '{regex}' in file '{relative_path}'."
return f"Error: No matches of search expression found in file '{relative_path}'."
if not allow_multiple_occurrences and n > 1:
return (
f"Error: Regex '{regex}' matches {n} occurrences in file '{relative_path}'. "
"Please revise the regex to be more specific or enable allow_multiple_occurrences if this is expected."
f"Error: Expression matches {n} occurrences in file '{relative_path}'. "
"Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected."
)
context.set_updated_content(updated_content)
return SUCCESS_RESULT
+22 -34
View File
@@ -6,51 +6,39 @@ from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient
class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
Performs a global (or local) search for symbols using the JetBrains backend
"""
def apply(
self,
name_path: str,
name_path_pattern: str,
depth: int = 0,
relative_path: str | None = None,
include_body: bool = False,
max_answer_chars: int = -1,
) -> str:
"""
Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given `name_path`,
which represents a pattern for the symbol's path within the symbol tree of a single file.
The returned symbol location can be used for edits or further queries.
Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given name path pattern.
The returned symbol information can be used for edits or further queries.
Specify `depth > 0` to retrieve children (e.g., methods of a class).
The matching behavior is determined by the structure of `name_path`, which can
either be a simple name (e.g. "method") or a name path like "class/method" (relative name path)
or "/class/method" (absolute name path).
Note that the name path is not a path in the file system but rather a path in the symbol tree
**within a single file**. Thus, file or directory names should never be included in the `name_path`.
For restricting the search to a single file or directory, pass the `relative_path` parameter.
The retrieved symbols' `name_path` attribute will always be composed of symbol names, never file
or directory names.
A name path is a path in the symbol tree *within a source file*.
For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`.
If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to
uniquely identify it.
Key aspects of the name path matching behavior:
- The name of the retrieved symbols will match the last segment of `name_path`, while preceding segments
will restrict the search to symbols that have a desired sequence of ancestors.
- If there is no `/` in `name_path`, there is no restriction on the ancestor symbols.
For example, passing `method` will match against all symbols with name paths like `method`,
`class/method`, `class/nested_class/method`, etc.
- If `name_path` contains at least one `/`, the matching is restricted to symbols
with the respective ancestors. For example, passing `class/method` will match against
`class/method` as well as `nested_class/class/method` but not `other_class/method`.
- If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, i.e.
all ancestors are provided and must match.
For example, passing `/class` will match only against top-level symbols named `class` but
will not match `nested_class/class`. Passing `/class/method` will match `class/method` but
not `outer_class/class/method`.
To search for a symbol, you provide a name path pattern that is used to match against name paths.
It can be
* a simple name (e.g. "method"), which will match any symbol with that name
* a relative path like "class/method", which will match any symbol with that name path suffix
* an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file.
Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]".
:param name_path: The name path pattern to search for, see above for details.
:param depth: Depth to retrieve descendants (e.g., 1 for class methods/attributes).
:param relative_path: Optional. Restrict search to this file or directory.
If None, searches entire codebase.
:param name_path_pattern: the name path matching pattern (see above)
:param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children;
for the case where the symbol is a class, this will return its methods).
Default 0.
:param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase.
If a directory is passed, the search will be restricted to the files in that directory.
If a file is passed, the search will be restricted to that file.
If you have some knowledge about the codebase, you should use this parameter, as it will significantly
@@ -62,7 +50,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
with JetBrainsPluginClient.from_project(self.project) as client:
response_dict = client.find_symbol(
name_path=name_path,
name_path=name_path_pattern,
relative_path=relative_path,
depth=depth,
include_body=include_body,
@@ -73,7 +61,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Finds symbols that reference the given symbol
Finds symbols that reference the given symbol using the JetBrains backend
"""
def apply(
@@ -104,7 +92,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark
class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
"""
Retrieves an overview of the top-level symbols within a specified file
Retrieves an overview of the top-level symbols within a specified file using the JetBrains backend
"""
def apply(
+30 -4
View File
@@ -1,6 +1,7 @@
import json
from typing import Literal
from serena.tools import Tool
from serena.tools import ReplaceContentTool, Tool
class WriteMemoryTool(Tool):
@@ -8,7 +9,7 @@ class WriteMemoryTool(Tool):
Writes a named memory (for future reference) to Serena's project-specific memory store.
"""
def apply(self, memory_name: str, content: str, max_answer_chars: int = -1) -> str:
def apply(self, memory_file_name: str, content: str, max_answer_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.
The memory name should be meaningful.
@@ -18,10 +19,11 @@ class WriteMemoryTool(Tool):
max_answer_chars = self.agent.serena_config.default_max_tool_answer_chars
if len(content) > max_answer_chars:
raise ValueError(
f"Content for {memory_name} is too long. Max length is {max_answer_chars} characters. " + "Please make the content shorter."
f"Content for {memory_file_name} is too long. Max length is {max_answer_chars} characters. "
+ "Please make the content shorter."
)
return self.memories_manager.save_memory(memory_name, content)
return self.memories_manager.save_memory(memory_file_name, content)
class ReadMemoryTool(Tool):
@@ -63,3 +65,27 @@ class DeleteMemoryTool(Tool):
or no longer relevant for the project.
"""
return self.memories_manager.delete_memory(memory_file_name)
class EditMemoryTool(Tool):
def apply(
self,
memory_file_name: str,
needle: str,
repl: str,
mode: Literal["literal", "regex"],
) -> str:
r"""
Replaces content matching a regular expression in a memory.
:param memory_file_name: the name of the memory
:param needle: the string or regex pattern to search for.
If `mode` is "literal", this string will be matched exactly.
If `mode` is "regex", this string will be treated as a regular expression (syntax of Python's `re` module,
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.
"""
replace_content_tool = self.agent.get_tool(ReplaceContentTool)
rel_path = self.memories_manager.get_memory_file_path(memory_file_name).relative_to(self.get_project_root())
return replace_content_tool.replace_content(str(rel_path), needle, repl, mode=mode, require_not_ignored=False)
+25 -37
View File
@@ -42,7 +42,7 @@ class RestartLanguageServerTool(Tool, ToolMarkerOptional):
"""Use this tool only on explicit user request or after confirmation.
It may be necessary to restart the language server if it hangs.
"""
self.agent.reset_language_server()
self.agent.reset_language_server_manager()
return SUCCESS_RESULT
@@ -79,13 +79,13 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
"""
Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
Performs a global (or local) search using the language server backend.
"""
# noinspection PyDefaultArgument
def apply(
self,
name_path: str,
name_path_pattern: str,
depth: int = 0,
relative_path: str = "",
include_body: bool = False,
@@ -95,38 +95,26 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
max_answer_chars: int = -1,
) -> str:
"""
Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given `name_path`,
which represents a pattern for the symbol's path within the symbol tree of a single file.
The returned symbol location can be used for edits or further queries.
Specify `depth > 0` to retrieve children (e.g., methods of a class).
Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given name path pattern.
The returned symbol information can be used for edits or further queries.
Specify `depth > 0` to also retrieve children/descendants (e.g., methods of a class).
The matching behavior is determined by the structure of `name_path`, which can
either be a simple name (e.g. "method") or a name path like "class/method" (relative name path)
or "/class/method" (absolute name path). Note that the name path is not a path in the file system
but rather a path in the symbol tree **within a single file**. Thus, file or directory names should never
be included in the `name_path`. For restricting the search to a single file or directory,
the `within_relative_path` parameter should be used instead. The retrieved symbols' `name_path` attribute
will always be composed of symbol names, never file or directory names.
A name path is a path in the symbol tree *within a source file*.
For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`.
If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to
uniquely identify it.
Key aspects of the name path matching behavior:
- Trailing slashes in `name_path` play no role and are ignored.
- The name of the retrieved symbols will match (either exactly or as a substring)
the last segment of `name_path`, while other segments will restrict the search to symbols that
have a desired sequence of ancestors.
- If there is no starting or intermediate slash in `name_path`, there is no
restriction on the ancestor symbols. For example, passing `method` will match
against symbols with name paths like `method`, `class/method`, `class/nested_class/method`, etc.
- If `name_path` contains a `/` but doesn't start with a `/`, the matching is restricted to symbols
with the same ancestors as the last segment of `name_path`. For example, passing `class/method` will match against
`class/method` as well as `nested_class/class/method` but not `method`.
- If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, meaning
that the first segment of it must match the first segment of the symbol's name path.
For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`.
Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`.
To search for a symbol, you provide a name path pattern that is used to match against name paths.
It can be
* a simple name (e.g. "method"), which will match any symbol with that name
* a relative path like "class/method", which will match any symbol with that name path suffix
* an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file.
Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]".
:param name_path: The name path pattern to search for, see above for details.
:param depth: Depth to retrieve descendants (e.g., 1 for class methods/attributes).
:param name_path_pattern: the name path matching pattern (see above)
:param depth: depth up to which descendants shall be retrieved (e.g. use 1 to also retrieve immediate children;
for the case where the symbol is a class, this will return its methods).
Default 0.
:param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase.
If a directory is passed, the search will be restricted to the files in that directory.
If a file is passed, the search will be restricted to that file.
@@ -140,7 +128,8 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
If not provided, all kinds are included.
:param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`.
If not provided, no kinds are excluded.
:param substring_matching: If True, use substring matching for the last segment of `name`.
:param substring_matching: If True, use substring matching for the last element of the pattern, such that
"Foo/get" would match "Foo/getValue" and "Foo/getData".
:param max_answer_chars: Max characters for the JSON result. If exceeded, no content is returned.
-1 means the default value from the config will be used.
:return: a list of symbols (with locations) matching the name.
@@ -149,8 +138,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
symbol_retriever = self.create_language_server_symbol_retriever()
symbols = symbol_retriever.find_by_name(
name_path,
include_body=include_body,
name_path_pattern,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
substring_matching=substring_matching,
@@ -163,7 +151,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
"""
Finds symbols that reference the symbol at the given location (optionally filtered by type).
Finds symbols that reference the given symbol using the language server backend
"""
# noinspection PyDefaultArgument
@@ -216,7 +204,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead):
class ReplaceSymbolBodyTool(Tool, ToolMarkerSymbolicEdit):
"""
Replaces the full definition of a symbol.
Replaces the full definition of a symbol using the language server backend.
"""
def apply(
+28 -16
View File
@@ -47,9 +47,8 @@ class Component(ABC):
def create_language_server_symbol_retriever(self) -> LanguageServerSymbolRetriever:
if not self.agent.is_using_language_server():
raise Exception("Cannot create LanguageServerSymbolRetriever; agent is not in language server mode.")
language_server = self.agent.language_server
assert language_server is not None
return LanguageServerSymbolRetriever(language_server, agent=self.agent)
language_server_manager = self.agent.get_language_server_manager_or_raise()
return LanguageServerSymbolRetriever(language_server_manager, agent=self.agent)
@property
def project(self) -> Project:
@@ -239,33 +238,39 @@ class Tool(Component):
try:
# check whether the tool requires an active project and language server
if not isinstance(self, ToolMarkerDoesNotRequireActiveProject):
if self.agent._active_project is None:
if self.agent.get_active_project() is None:
return (
"Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: "
+ f"{self.agent.serena_config.project_names}"
)
if self.agent.is_using_language_server() and not self.agent.is_language_server_running():
log.info("Language server is not running. Starting it ...")
self.agent.reset_language_server()
# apply the actual tool
try:
result = apply_fn(**kwargs)
except SolidLSPException as e:
if e.is_language_server_terminated():
log.error(f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ...")
self.agent.reset_language_server()
result = apply_fn(**kwargs)
affected_language = e.get_affected_language()
if affected_language is not None:
log.error(
f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..."
)
self.agent.get_language_server_manager_or_raise().restart_language_server(affected_language)
result = apply_fn(**kwargs)
else:
log.error(
f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying."
)
raise
else:
raise
# record tool usage
self.agent.record_tool_usage_if_enabled(kwargs, result, self)
self.agent.record_tool_usage(kwargs, result, self)
except Exception as e:
if not catch_exceptions:
raise
msg = f"Error executing tool: {e}"
msg = f"Error executing tool: {e.__class__.__name__} - {e}"
log.error(f"Error executing tool: {e}", exc_info=e)
result = msg
@@ -273,15 +278,22 @@ class Tool(Component):
log.info(f"Result: {result}")
try:
if self.agent.language_server is not None:
self.agent.language_server.save_cache()
ls_manager = self.agent.get_language_server_manager()
if ls_manager is not None:
ls_manager.save_all_caches()
except Exception as e:
log.error(f"Error saving language server cache: {e}")
return result
future = self.agent.issue_task(task, name=self.__class__.__name__)
return future.result(timeout=self.agent.serena_config.tool_timeout)
# execute the tool in the agent's task executor, with timeout
try:
task_exec = self.agent.issue_task(task, name=self.__class__.__name__)
return task_exec.result(timeout=self.agent.serena_config.tool_timeout)
except Exception as e: # typically TimeoutError (other exceptions caught in task)
msg = f"Error: {e.__class__.__name__} - {e}"
log.error(msg)
return msg
class EditedFileContext:
+7 -8
View File
@@ -122,18 +122,17 @@ class PrepareForNewConversationTool(Tool):
return self.prompt_factory.create_prepare_for_new_conversation()
class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional):
class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject):
"""
Gets the initial instructions for the current project.
Should only be used in settings where the system prompt cannot be set,
e.g. in clients you have no control over, like Claude Desktop.
Provides instructions on how to use the Serena toolbox.
Should only be used in settings where the system prompt is not read automatically by the client.
NOTE: Some MCP clients (including Claude Desktop) do not read the system prompt automatically!
"""
def apply(self) -> str:
"""
Get the initial instructions for the current coding project.
If you haven't received instructions on how to use Serena's tools in the system prompt,
you should always call this tool before starting to work (including using any other tool) on any programming task,
the only exception being when you are asked to call `activate_project`, which you should then call before.
Provides the 'Serena Instructions Manual', which contains essential information on how to use the Serena toolbox.
Call this tool if you have not yet read this very important manual!
"""
return self.agent.create_system_prompt()
@@ -680,7 +680,7 @@ class ALLanguageServer(SolidLanguageServer):
return super().is_ignored_dirname(dirname) or dirname in al_ignore_dirs
@override
def request_full_symbol_tree(self, within_relative_path: str | None = None, include_body: bool = False) -> list[dict]:
def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[dict]:
"""
Override to handle AL's requirement of opening files before requesting symbols.
@@ -713,7 +713,7 @@ class ALLanguageServer(SolidLanguageServer):
if os.path.isfile(within_abs_path):
# Single file case - use parent class implementation
_, root_nodes = self.request_document_symbols(within_relative_path, include_body=include_body)
root_nodes = self.request_document_symbols(within_relative_path).root_symbols
return root_nodes
# Directory case - scan within this directory
@@ -755,7 +755,7 @@ class ALLanguageServer(SolidLanguageServer):
try:
# Use our overridden request_document_symbols which handles opening
self.logger.log(f"AL: Getting symbols for {relative_path}", logging.DEBUG)
all_syms, root_syms = self.request_document_symbols(relative_path, include_body=include_body)
all_syms, root_syms = self.request_document_symbols(relative_path).get_all_symbols_and_roots()
if root_syms:
# Create a file-level symbol containing the document symbols
@@ -9,9 +9,8 @@ import pathlib
import shutil
import threading
from solidlsp import ls_types
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
@@ -188,43 +187,31 @@ class BashLanguageServer(SolidLanguageServer):
self.logger.log("Waiting for Bash language server to be ready...", logging.INFO)
if not self.server_ready.wait(timeout=3.0):
# Fallback: assume server is ready after timeout
self.logger.log("Timeout waiting for bash server ready signal, proceeding anyway", logging.WARNING)
# This is common. bash-language-server doesn't always send explicit ready signals. Log as info
self.logger.log("Timeout waiting for bash server ready signal, proceeding anyway", logging.INFO)
self.server_ready.set()
self.completions_available.set()
else:
self.logger.log("Bash server initialization complete", logging.INFO)
def request_document_symbols(
self, relative_file_path: str, include_body: bool = False
) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]:
"""
Request document symbols from bash-language-server via LSP.
def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
# Uses the standard LSP documentSymbol request which provides reliable function detection
# for all bash function syntaxes including:
# - function name() { ... } (with function keyword)
# - name() { ... } (traditional syntax)
# - Functions with various indentation levels
# - Functions with comments before/after/inside
Uses the standard LSP documentSymbol request which provides reliable function detection
for all bash function syntaxes including:
- function name() { ... } (with function keyword)
- name() { ... } (traditional syntax)
- Functions with various indentation levels
- Functions with comments before/after/inside
Args:
relative_file_path: Path to the bash file relative to repository root
include_body: Whether to include function bodies in symbol information
Returns:
Tuple of (all_symbols, root_symbols) detected by the LSP server
"""
self.logger.log(f"Requesting document symbols via LSP for {relative_file_path}", logging.DEBUG)
# Use the standard LSP approach - bash-language-server handles all function syntaxes correctly
all_symbols, root_symbols = super().request_document_symbols(relative_file_path, include_body)
document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer)
# Log detection results for debugging
functions = [s for s in all_symbols if s.get("kind") == 12]
functions = [s for s in document_symbols.iter_symbols() if s.get("kind") == 12]
self.logger.log(
f"LSP function detection for {relative_file_path}: Found {len(functions)} functions",
logging.INFO,
)
return all_symbols, root_symbols
return document_symbols
@@ -52,6 +52,8 @@ class ClangdLanguageServer(SolidLanguageServer):
"""
Setup runtime dependencies for ClangdLanguageServer and return the command to start the server.
"""
import shutil
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
@@ -90,21 +92,40 @@ class ClangdLanguageServer(SolidLanguageServer):
)
clangd_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "clangd")
dep = deps.get_single_dep_for_current_platform()
clangd_executable_path = deps.binary_path(clangd_ls_dir)
if not os.path.exists(clangd_executable_path):
logger.log(
f"Clangd executable not found at {clangd_executable_path}. Downloading from {dep.url}",
logging.INFO,
)
deps.install(logger, clangd_ls_dir)
if not os.path.exists(clangd_executable_path):
raise FileNotFoundError(
f"Clangd executable not found at {clangd_executable_path}.\n"
"Make sure you have installed clangd. See https://clangd.llvm.org/installation"
)
os.chmod(clangd_executable_path, 0o755)
try:
dep = deps.get_single_dep_for_current_platform()
except RuntimeError:
dep = None
if dep is None:
# No prebuilt binary available, look for system-installed clangd
clangd_executable_path = shutil.which("clangd")
if not clangd_executable_path:
raise FileNotFoundError(
"Clangd is not installed on your system.\n"
+ "Please install clangd using your system package manager:\n"
+ " Ubuntu/Debian: sudo apt-get install clangd\n"
+ " Fedora/RHEL: sudo dnf install clang-tools-extra\n"
+ " Arch Linux: sudo pacman -S clang\n"
+ "See https://clangd.llvm.org/installation for more details."
)
logger.log(f"Using system-installed clangd at {clangd_executable_path}", logging.INFO)
else:
# Standard download and install for platforms with prebuilt binaries
clangd_executable_path = deps.binary_path(clangd_ls_dir)
if not os.path.exists(clangd_executable_path):
logger.log(
f"Clangd executable not found at {clangd_executable_path}. Downloading from {dep.url}",
logging.INFO,
)
_ = deps.install(logger, clangd_ls_dir)
if not os.path.exists(clangd_executable_path):
raise FileNotFoundError(
f"Clangd executable not found at {clangd_executable_path}.\n"
+ "Make sure you have installed clangd. See https://clangd.llvm.org/installation"
)
os.chmod(clangd_executable_path, 0o755)
return clangd_executable_path
@staticmethod
@@ -248,17 +248,10 @@ class CSharpLanguageServer(SolidLanguageServer):
Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available.
Returns a tuple of (dotnet_path, language_server_dll_path).
"""
language_enum = cls.get_language_enum_instance()
ls_specific_settings = solidlsp_settings.ls_specific_settings or {}
language_specific_config = ls_specific_settings.get(language_enum, {})
language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
runtime_dependency_overrides = cast(list[dict[str, Any]], language_specific_config.get("runtime_dependencies", []))
logger.log("Resolving runtime dependencies", logging.DEBUG)
if language_specific_config:
logger.log(
f"Language-specific config for {language_enum}: {language_specific_config}",
logging.DEBUG,
)
runtime_dependencies = RuntimeDependencyCollection(
_RUNTIME_DEPENDENCIES,
@@ -452,9 +445,8 @@ class CSharpLanguageServer(SolidLanguageServer):
logger.log("Downloading .NET 9 runtime...", logging.INFO)
dotnet_dir.mkdir(parents=True, exist_ok=True)
custom_dotnet_runtime_url = solidlsp_settings.ls_specific_settings.get(cls.get_language_enum_instance(), {}).get(
"dotnet_runtime_url"
)
custom_settings = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
custom_dotnet_runtime_url = custom_settings.get("dotnet_runtime_url")
if custom_dotnet_runtime_url is not None:
logger.log(f"Using custom .NET runtime url: {custom_dotnet_runtime_url}", logging.INFO)
url = custom_dotnet_runtime_url
+126 -40
View File
@@ -14,7 +14,7 @@ from typing import cast
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls import GenericDocumentSymbol, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.ls_utils import FileUtils, PlatformUtils
@@ -40,8 +40,24 @@ class RuntimeDependencyPaths:
class EclipseJDTLS(SolidLanguageServer):
"""
r"""
The EclipseJDTLS class provides a Java specific implementation of the LanguageServer class
You can configure the following options in ls_specific_settings (in serena_config.yml):
- maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml)
- gradle_user_home: Path to Gradle user home directory (default: ~/.gradle)
Note: Gradle wrapper is disabled by default. Projects will use the bundled Gradle distribution.
Example configuration in ~/.serena/serena_config.yml:
```yaml
ls_specific_settings:
java:
maven_user_settings: "/home/user/.m2/settings.xml" # Unix/Linux/Mac
# maven_user_settings: 'C:\\Users\\YourName\\.m2\\settings.xml' # Windows (use single quotes!)
gradle_user_home: "/home/user/.gradle" # Unix/Linux/Mac
# gradle_user_home: 'C:\\Users\\YourName\\.gradle' # Windows (use single quotes!)
```
"""
def __init__(
@@ -94,41 +110,39 @@ class EclipseJDTLS(SolidLanguageServer):
# TODO: Add "self.runtime_dependency_paths.jre_home_path"/bin to $PATH as well
proc_env = {"syntaxserver": "false", "JAVA_HOME": self.runtime_dependency_paths.jre_home_path}
proc_cwd = repository_root_path
cmd = " ".join(
[
jre_path,
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"--add-opens",
"java.base/sun.nio.fs=ALL-UNNAMED",
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Djava.import.generatesMetadataFilesAtProjectRoot=false",
"-Dfile.encoding=utf8",
"-noverify",
"-XX:+UseParallelGC",
"-XX:GCTimeRatio=4",
"-XX:AdaptiveSizePolicyWeight=90",
"-Dsun.zip.disableMemoryMapping=true",
"-Djava.lsp.joinOnCompletion=true",
"-Xmx3G",
"-Xms100m",
"-Xlog:disable",
"-Dlog.level=ALL",
f'"-javaagent:{lombok_jar_path}"',
f'"-Djdt.core.sharedIndexLocation={shared_cache_location}"',
"-jar",
f'"{jdtls_launcher_jar}"',
"-configuration",
f'"{jdtls_config_path}"',
"-data",
f'"{data_dir}"',
]
)
cmd = [
jre_path,
"--add-modules=ALL-SYSTEM",
"--add-opens",
"java.base/java.util=ALL-UNNAMED",
"--add-opens",
"java.base/java.lang=ALL-UNNAMED",
"--add-opens",
"java.base/sun.nio.fs=ALL-UNNAMED",
"-Declipse.application=org.eclipse.jdt.ls.core.id1",
"-Dosgi.bundles.defaultStartLevel=4",
"-Declipse.product=org.eclipse.jdt.ls.core.product",
"-Djava.import.generatesMetadataFilesAtProjectRoot=false",
"-Dfile.encoding=utf8",
"-noverify",
"-XX:+UseParallelGC",
"-XX:GCTimeRatio=4",
"-XX:AdaptiveSizePolicyWeight=90",
"-Dsun.zip.disableMemoryMapping=true",
"-Djava.lsp.joinOnCompletion=true",
"-Xmx3G",
"-Xms100m",
"-Xlog:disable",
"-Dlog.level=ALL",
f"-javaagent:{lombok_jar_path}",
f"-Djdt.core.sharedIndexLocation={shared_cache_location}",
"-jar",
f"{jdtls_launcher_jar}",
"-configuration",
f"{jdtls_config_path}",
"-data",
f"{data_dir}",
]
self.service_ready_event = threading.Event()
self.intellicode_enable_command_available = threading.Event()
@@ -327,6 +341,56 @@ class EclipseJDTLS(SolidLanguageServer):
repository_absolute_path = os.path.abspath(repository_absolute_path)
repo_uri = pathlib.Path(repository_absolute_path).as_uri()
# Load user's Maven and Gradle configuration paths from ls_specific_settings["java"]
# Maven settings: default to ~/.m2/settings.xml
default_maven_settings_path = os.path.join(os.path.expanduser("~"), ".m2", "settings.xml")
custom_maven_settings_path = self._custom_settings.get("maven_user_settings")
if custom_maven_settings_path is not None:
# User explicitly provided a path
if not os.path.exists(custom_maven_settings_path):
error_msg = (
f"Provided maven settings file not found: {custom_maven_settings_path}. "
f"Fix: create the file, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> maven_user_settings), "
f"or remove the setting to use default ({default_maven_settings_path})"
)
self.logger.log(error_msg, logging.ERROR)
raise FileNotFoundError(error_msg)
maven_settings_path = custom_maven_settings_path
self.logger.log(f"Using Maven settings from custom location: {maven_settings_path}", logging.INFO)
elif os.path.exists(default_maven_settings_path):
maven_settings_path = default_maven_settings_path
self.logger.log(f"Using Maven settings from default location: {maven_settings_path}", logging.INFO)
else:
maven_settings_path = None
self.logger.log(
f"Maven settings not found at default location ({default_maven_settings_path}), will use JDTLS defaults", logging.INFO
)
# Gradle user home: default to ~/.gradle
default_gradle_home = os.path.join(os.path.expanduser("~"), ".gradle")
custom_gradle_home = self._custom_settings.get("gradle_user_home")
if custom_gradle_home is not None:
# User explicitly provided a path
if not os.path.exists(custom_gradle_home):
error_msg = (
f"Gradle user home directory not found: {custom_gradle_home}. "
f"Fix: create the directory, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_user_home), "
f"or remove the setting to use default (~/.gradle)"
)
self.logger.log(error_msg, logging.ERROR)
raise FileNotFoundError(error_msg)
gradle_user_home = custom_gradle_home
self.logger.log(f"Using Gradle user home from custom location: {gradle_user_home}", logging.INFO)
elif os.path.exists(default_gradle_home):
gradle_user_home = default_gradle_home
self.logger.log(f"Using Gradle user home from default location: {gradle_user_home}", logging.INFO)
else:
gradle_user_home = None
self.logger.log(
f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults", logging.INFO
)
initialize_params = {
"locale": "en",
"rootPath": repository_absolute_path,
@@ -513,7 +577,7 @@ class EclipseJDTLS(SolidLanguageServer):
"checkProjectSettingsExclusions": False,
"updateBuildConfiguration": "interactive",
"maven": {
"userSettings": None,
"userSettings": maven_settings_path,
"globalSettings": None,
"notCoveredPluginExecutionSeverity": "warning",
"defaultMojoExecutionAction": "ignore",
@@ -539,7 +603,7 @@ class EclipseJDTLS(SolidLanguageServer):
"offline": {"enabled": False},
"arguments": None,
"jvmArguments": None,
"user": {"home": None},
"user": {"home": gradle_user_home},
"annotationProcessing": {"enabled": True},
},
"exclusions": [
@@ -550,7 +614,9 @@ class EclipseJDTLS(SolidLanguageServer):
],
"generatesMetadataFilesAtProjectRoot": False,
},
"maven": {"downloadSources": True, "updateSnapshots": True},
# Set updateSnapshots to False to improve performance and avoid unnecessary network calls
# Snapshots will only be updated when explicitly requested by the user
"maven": {"downloadSources": True, "updateSnapshots": False},
"eclipse": {"downloadSources": True},
"signatureHelp": {"enabled": True, "description": {"enabled": True}},
"implementationsCodeLens": {"enabled": True},
@@ -722,3 +788,23 @@ class EclipseJDTLS(SolidLanguageServer):
# TODO: Add comments about why we wait here, and how this can be optimized
self.service_ready_event.wait()
def _request_document_symbols(self, relative_file_path: str, file_data: LSPFileBuffer | None) -> list[GenericDocumentSymbol]:
result = super()._request_document_symbols(relative_file_path, file_data=file_data)
# JDTLS sometimes returns symbol names with type information to handle overloads,
# e.g. "myMethod(int) <T>", but we want overloads to be handled via overload_idx,
# which requires the name to be just "myMethod".
def fix_name(symbol: GenericDocumentSymbol):
if "(" in symbol["name"]:
symbol["name"] = symbol["name"][: symbol["name"].index("(")]
children = symbol.get("children")
if children:
for child in children:
fix_name(child)
for root_symbol in result:
fix_name(root_symbol)
return result
@@ -4,7 +4,6 @@ import pathlib
import stat
import subprocess
import threading
import time
from typing import Any, cast
from overrides import override
@@ -35,6 +34,15 @@ class ElixirTools(SolidLanguageServer):
# - cover: coverage reports
return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", "cover"]
@override
def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool:
"""Check if a path should be ignored for symbol indexing."""
if relative_path.endswith("mix.exs"):
# These are project configuration files, not source code with symbols to index
return True
return super().is_ignored_path(relative_path, ignore_unsupported_files)
def _is_next_ls_internal_file(self, abs_path: str) -> bool:
"""Check if an absolute path is a Next LS internal file that should be ignored."""
return any(
@@ -253,6 +261,11 @@ class ElixirTools(SolidLanguageServer):
"didChangeConfiguration": {"dynamicRegistration": True},
"executeCommand": {"dynamicRegistration": True},
},
"window": {
"showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}},
"showDocument": {"support": True},
"workDoneProgress": True,
},
},
"workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}],
}
@@ -307,7 +320,7 @@ class ElixirTools(SolidLanguageServer):
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", check_server_ready)
self.server.on_notification("window/workDoneProgress/create", do_nothing)
self.server.on_request("window/workDoneProgress/create", do_nothing)
self.server.on_notification("$/workDoneProgress", work_done_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
@@ -327,11 +340,12 @@ class ElixirTools(SolidLanguageServer):
# Next LS may not provide all capabilities immediately, so we check for basic ones
assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}"
# Some capabilities might be optional or provided later
# Some capabilities might be optional or provided later. This is expected, so we log as info
if "completionProvider" not in init_response["capabilities"]:
self.logger.log("Warning: completionProvider not available in initial capabilities", logging.WARNING)
self.logger.log("completionProvider not available in initial capabilities", logging.INFO)
if "definitionProvider" not in init_response["capabilities"]:
self.logger.log("Warning: definitionProvider not available in initial capabilities", logging.WARNING)
self.logger.log("definitionProvider not available in initial capabilities", logging.INFO)
self.server.notify.initialized({})
self.completions_available.set()
@@ -344,12 +358,6 @@ class ElixirTools(SolidLanguageServer):
if self.server_ready.wait(timeout=ready_timeout):
self.logger.log("Next LS is ready and available for requests", logging.INFO)
# Add a small settling period to ensure background indexing is complete
# Next LS often continues compilation/indexing in background after ready signal
settling_time = 120.0
self.logger.log(f"Allowing {settling_time} seconds for Next LS background indexing to complete...", logging.INFO)
time.sleep(settling_time)
self.logger.log("Next LS settling period complete", logging.INFO)
else:
error_msg = f"Next LS failed to initialize within {ready_timeout} seconds. This may indicate a problem with the Elixir installation, project compilation, or Next LS itself."
self.logger.log(error_msg, logging.ERROR)
@@ -0,0 +1,306 @@
"""
Fortran Language Server implementation using fortls.
"""
import logging
import os
import pathlib
import re
import shutil
import threading
from overrides import override
from solidlsp import ls_types
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
class FortranLanguageServer(SolidLanguageServer):
"""Fortran Language Server implementation using fortls."""
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 3.0 # fortls needs time for workspace indexing
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For Fortran projects, ignore common build directories
return super().is_ignored_dirname(dirname) or dirname in [
"build",
"Build",
"BUILD",
"bin",
"lib",
"mod", # Module files directory
"obj", # Object files directory
".cmake",
"CMakeFiles",
]
def _fix_fortls_selection_range(
self, symbol: ls_types.UnifiedSymbolInformation, file_content: str
) -> ls_types.UnifiedSymbolInformation:
"""
Fix fortls's incorrect selectionRange that points to line start instead of identifier name.
fortls bug: selectionRange.start.character is 0 (line start) but should point to the
function/subroutine/module/program name position. This breaks MCP server features that
rely on the exact identifier position for finding references.
Args:
symbol: The symbol with potentially incorrect selectionRange
file_content: Full file content to parse the line
Returns:
Symbol with corrected selectionRange pointing to the identifier name
"""
if "selectionRange" not in symbol:
return symbol
sel_range = symbol["selectionRange"]
start_line = sel_range["start"]["line"]
start_char = sel_range["start"]["character"]
# Split file content into lines
lines = file_content.split("\n")
if start_line >= len(lines):
return symbol
line = lines[start_line]
# Fortran keywords that define named constructs
# Match patterns:
# Standard keywords: <keyword> <whitespace> <identifier_name>
# " function add_numbers(a, b) result(sum)" -> keyword="function", name="add_numbers"
# "subroutine print_result(value)" -> keyword="subroutine", name="print_result"
# "module math_utils" -> keyword="module", name="math_utils"
# "program test_program" -> keyword="program", name="test_program"
# "interface distance" -> keyword="interface", name="distance"
#
# Type definitions (can have :: syntax):
# "type point" -> keyword="type", name="point"
# "type :: point" -> keyword="type", name="point"
# "type, extends(base) :: derived" -> keyword="type", name="derived"
#
# Submodules (have parent module in parentheses):
# "submodule (parent_mod) child_mod" -> keyword="submodule", name="child_mod"
# Try type pattern first (has complex syntax with optional comma and ::)
type_pattern = r"^\s*type\s*(?:,.*?)?\s*(?:::)?\s*([a-zA-Z_]\w*)"
match = re.match(type_pattern, line, re.IGNORECASE)
if match:
# For type pattern, identifier is in group 1
identifier_name = match.group(1)
identifier_start = match.start(1)
else:
# Try standard keywords pattern
standard_pattern = r"^\s*(function|subroutine|module|program|interface)\s+([a-zA-Z_]\w*)"
match = re.match(standard_pattern, line, re.IGNORECASE)
if not match:
# Try submodule pattern
submodule_pattern = r"^\s*submodule\s*\([^)]+\)\s+([a-zA-Z_]\w*)"
match = re.match(submodule_pattern, line, re.IGNORECASE)
if match:
identifier_name = match.group(1)
identifier_start = match.start(1)
else:
identifier_name = match.group(2)
identifier_start = match.start(2)
if match:
# Create corrected selectionRange
new_sel_range = {
"start": {"line": start_line, "character": identifier_start},
"end": {"line": start_line, "character": identifier_start + len(identifier_name)},
}
# Create modified symbol with corrected selectionRange
corrected_symbol = symbol.copy()
corrected_symbol["selectionRange"] = new_sel_range
self.logger.log(
f"Fixed fortls selectionRange for {identifier_name}: char {start_char} -> {identifier_start}",
logging.DEBUG,
)
return corrected_symbol
# If no match, return symbol unchanged (e.g., for variables, which don't have this pattern)
return symbol
@override
def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
# Override to fix fortls's incorrect selectionRange bug.
#
# fortls returns selectionRange pointing to line start (character 0) instead of the
# identifier name position. This breaks MCP server features that rely on exact positions.
#
# This override:
# 1. Gets symbols from fortls via parent implementation
# 2. Parses each symbol's line to find the correct identifier position
# 3. Fixes selectionRange for all symbols recursively
# 4. Returns corrected symbols
# Get symbols from fortls (with incorrect selectionRange)
document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer)
# Get file content for parsing
with self.open_file(relative_file_path) as file_data:
file_content = file_data.contents
# Fix selectionRange recursively for all symbols
def fix_symbol_and_children(symbol: ls_types.UnifiedSymbolInformation) -> ls_types.UnifiedSymbolInformation:
# Fix this symbol's selectionRange
fixed = self._fix_fortls_selection_range(symbol, file_content)
# Fix children recursively
if fixed.get("children"):
fixed["children"] = [fix_symbol_and_children(child) for child in fixed["children"]]
return fixed
# Apply fix to all symbols
fixed_root_symbols = [fix_symbol_and_children(sym) for sym in document_symbols.root_symbols]
return DocumentSymbols(fixed_root_symbols)
@staticmethod
def _check_fortls_installation():
"""Check if fortls is available."""
fortls_path = shutil.which("fortls")
if fortls_path is None:
raise RuntimeError("fortls is not installed or not in PATH.\nInstall it with: pip install fortls")
return fortls_path
def __init__(
self,
config: LanguageServerConfig,
logger: LanguageServerLogger,
repository_root_path: str,
solidlsp_settings: SolidLSPSettings,
):
# Check fortls installation
fortls_path = self._check_fortls_installation()
# Command to start fortls language server
# fortls uses stdio for LSP communication by default
fortls_cmd = f"{fortls_path}"
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=fortls_cmd, cwd=repository_root_path),
"fortran", # Language ID for LSP
solidlsp_settings,
)
self.server_ready = threading.Event()
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""Initialize params for Fortran Language Server."""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {
"dynamicRegistration": True,
"completionItem": {
"snippetSupport": True,
"commitCharactersSupport": True,
"documentationFormat": ["markdown", "plaintext"],
"deprecatedSupport": True,
"preselectSupport": True,
},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
},
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params
def _start_server(self):
"""Start Fortran Language Server process."""
def window_log_message(msg):
self.logger.log(f"Fortran LSP: window/logMessage: {msg}", logging.INFO)
def do_nothing(params):
return
def register_capability_handler(params):
return
# Register LSP message handlers
self.server.on_request("client/registerCapability", register_capability_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)
self.logger.log("Starting Fortran Language Server (fortls) process", logging.INFO)
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
self.logger.log(
"Sending initialize request to Fortran Language Server",
logging.INFO,
)
init_response = self.server.send.initialize(initialize_params)
# Verify server capabilities
capabilities = init_response.get("capabilities", {})
assert "textDocumentSync" in capabilities
if "completionProvider" in capabilities:
self.logger.log("Fortran LSP completion provider available", logging.INFO)
if "definitionProvider" in capabilities:
self.logger.log("Fortran LSP definition provider available", logging.INFO)
if "referencesProvider" in capabilities:
self.logger.log("Fortran LSP references provider available", logging.INFO)
if "documentSymbolProvider" in capabilities:
self.logger.log("Fortran LSP document symbol provider available", logging.INFO)
self.server.notify.initialized({})
self.completions_available.set()
# Fortran Language Server is ready after initialization
self.server_ready.set()
self.logger.log("Fortran Language Server initialization complete", logging.INFO)
+18
View File
@@ -28,6 +28,24 @@ class Gopls(SolidLanguageServer):
# - dist/build: common output directories
return super().is_ignored_dirname(dirname) or dirname in ["vendor", "node_modules", "dist", "build"]
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify gopls stderr output to avoid false-positive errors."""
line_lower = line.lower()
# File discovery messages that are not actual errors
if any(
[
"discover.go:" in line_lower,
"walker.go:" in line_lower,
"walking of {file://" in line_lower,
"bus: -> discover" in line_lower,
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
@staticmethod
def _get_go_version() -> str | None:
"""Get the installed Go version or None if not found."""
@@ -0,0 +1,388 @@
"""
Provides Haskell specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Haskell.
"""
import logging
import os
import pathlib
import shutil
import time
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
class HaskellLanguageServer(SolidLanguageServer):
"""
Provides Haskell specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Haskell.
Uses Haskell Language Server (HLS) for LSP functionality.
"""
@staticmethod
def _ensure_hls_installed():
"""Ensure haskell-language-server-wrapper is available."""
# Try common locations
common_paths = [
shutil.which("haskell-language-server-wrapper"),
"/opt/homebrew/bin/haskell-language-server-wrapper",
"/usr/local/bin/haskell-language-server-wrapper",
os.path.expanduser("~/.ghcup/bin/haskell-language-server-wrapper"),
os.path.expanduser("~/.cabal/bin/haskell-language-server-wrapper"),
os.path.expanduser("~/.local/bin/haskell-language-server-wrapper"),
]
# Check Stack programs directory
stack_programs = os.path.expanduser("~/.local/share/stack/programs")
if os.path.exists(stack_programs):
try:
for arch_dir in os.listdir(stack_programs):
arch_path = os.path.join(stack_programs, arch_dir)
if os.path.isdir(arch_path):
try:
for ghc_dir in os.listdir(arch_path):
hls_path = os.path.join(arch_path, ghc_dir, "bin", "haskell-language-server-wrapper")
if os.path.isfile(hls_path) and os.access(hls_path, os.X_OK):
common_paths.append(hls_path)
except (PermissionError, OSError):
# Skip directories we can't read
continue
except (PermissionError, OSError):
# Stack programs directory not accessible
pass
for path in common_paths:
if path and os.path.isfile(path) and os.access(path, os.X_OK):
return path
raise RuntimeError(
"haskell-language-server-wrapper is not installed or not in PATH.\n"
"Searched locations:\n" + "\n".join(f" - {p}" for p in common_paths if p) + "\n"
"Please install HLS via:\n"
" - GHCup: https://www.haskell.org/ghcup/\n"
" - Stack: stack install haskell-language-server\n"
" - Cabal: cabal install haskell-language-server\n"
" - Homebrew (macOS): brew install haskell-language-server"
)
def __init__(
self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings
):
"""
Creates a HaskellLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
"""
hls_executable_path = self._ensure_hls_installed()
logger.log(f"Using haskell-language-server at: {hls_executable_path}", logging.INFO)
# Check if there's a haskell subdirectory with Stack/Cabal project
haskell_subdir = os.path.join(repository_root_path, "haskell")
if os.path.exists(haskell_subdir) and (
os.path.exists(os.path.join(haskell_subdir, "stack.yaml")) or os.path.exists(os.path.join(haskell_subdir, "cabal.project"))
):
working_dir = haskell_subdir
logger.log(f"Using Haskell project directory: {working_dir}", logging.INFO)
else:
working_dir = repository_root_path
# Set up environment with GHCup bin in PATH
env = dict(os.environ)
ghcup_bin = os.path.expanduser("~/.ghcup/bin")
if ghcup_bin not in env.get("PATH", ""):
env["PATH"] = f"{ghcup_bin}{os.pathsep}{env.get('PATH', '')}"
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=[hls_executable_path, "--lsp", "--cwd", working_dir], cwd=working_dir, env=env),
"haskell",
solidlsp_settings,
)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in ["dist", "dist-newstyle", ".stack-work", ".cabal-sandbox"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Haskell Language Server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"clientInfo": {"name": "Serena", "version": "0.1.0"},
"locale": "en",
"capabilities": {
"workspace": {
"applyEdit": True,
"workspaceEdit": {
"documentChanges": True,
"resourceOperations": ["create", "rename", "delete"],
"failureHandling": "textOnlyTransactional",
"normalizesLineEndings": True,
"changeAnnotationSupport": {"groupsOnLabel": True},
},
"configuration": True,
"didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True},
"symbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
"tagSupport": {"valueSet": [1]},
"resolveSupport": {"properties": ["location.range"]},
},
"executeCommand": {"dynamicRegistration": True},
"didChangeConfiguration": {"dynamicRegistration": True},
"workspaceFolders": True,
"semanticTokens": {"refreshSupport": True},
},
"textDocument": {
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [1, 2]},
"codeDescriptionSupport": True,
"dataSupport": True,
},
"synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True},
"completion": {
"dynamicRegistration": True,
"contextSupport": True,
"completionItem": {
"snippetSupport": True,
"commitCharactersSupport": True,
"documentationFormat": ["markdown", "plaintext"],
"deprecatedSupport": True,
"preselectSupport": True,
"tagSupport": {"valueSet": [1]},
"insertReplaceSupport": True,
"resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]},
"insertTextModeSupport": {"valueSet": [1, 2]},
"labelDetailsSupport": True,
},
"insertTextMode": 2,
"completionItemKind": {
"valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]
},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"signatureHelp": {
"dynamicRegistration": True,
"signatureInformation": {
"documentationFormat": ["markdown", "plaintext"],
"parameterInformation": {"labelOffsetSupport": True},
"activeParameterSupport": True,
},
"contextSupport": True,
},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentHighlight": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 27))},
"hierarchicalDocumentSymbolSupport": True,
"tagSupport": {"valueSet": [1]},
"labelSupport": True,
},
"codeAction": {
"dynamicRegistration": True,
"isPreferredSupport": True,
"disabledSupport": True,
"dataSupport": True,
"resolveSupport": {"properties": ["edit"]},
"codeActionLiteralSupport": {
"codeActionKind": {
"valueSet": [
"",
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
]
}
},
"honorsChangeAnnotations": False,
},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
"onTypeFormatting": {"dynamicRegistration": True},
"rename": {
"dynamicRegistration": True,
"prepareSupport": True,
"prepareSupportDefaultBehavior": 1,
"honorsChangeAnnotations": True,
},
"documentLink": {"dynamicRegistration": True, "tooltipSupport": True},
"typeDefinition": {"dynamicRegistration": True, "linkSupport": True},
"implementation": {"dynamicRegistration": True, "linkSupport": True},
"colorProvider": {"dynamicRegistration": True},
"foldingRange": {
"dynamicRegistration": True,
"rangeLimit": 5000,
"lineFoldingOnly": True,
"foldingRangeKind": {"valueSet": ["comment", "imports", "region"]},
},
"declaration": {"dynamicRegistration": True, "linkSupport": True},
"selectionRange": {"dynamicRegistration": True},
"callHierarchy": {"dynamicRegistration": True},
"semanticTokens": {
"dynamicRegistration": True,
"tokenTypes": [
"namespace",
"type",
"class",
"enum",
"interface",
"struct",
"typeParameter",
"parameter",
"variable",
"property",
"enumMember",
"event",
"function",
"method",
"macro",
"keyword",
"modifier",
"comment",
"string",
"number",
"regexp",
"operator",
],
"tokenModifiers": [
"declaration",
"definition",
"readonly",
"static",
"deprecated",
"abstract",
"async",
"modification",
"documentation",
"defaultLibrary",
],
"formats": ["relative"],
"requests": {"range": True, "full": {"delta": True}},
"multilineTokenSupport": False,
"overlappingTokenSupport": False,
},
"linkedEditingRange": {"dynamicRegistration": True},
},
"window": {
"showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}},
"showDocument": {"support": True},
"workDoneProgress": True,
},
"general": {
"staleRequestSupport": {"cancel": True, "retryOnContentModified": []},
"regularExpressions": {"engine": "ECMAScript", "version": "ES2020"},
"markdown": {
"parser": "marked",
"version": "1.1.0",
},
"positionEncodings": ["utf-16"],
},
},
"initializationOptions": {
"haskell": {
"formattingProvider": "ormolu",
"checkProject": True,
}
},
"trace": "verbose",
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params
def _start_server(self):
"""
Starts the Haskell Language Server
"""
def do_nothing(params):
return
def window_log_message(msg):
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
def register_capability_handler(params):
"""Handle dynamic capability registration from HLS"""
if "registrations" in params:
for registration in params.get("registrations", []):
method = registration.get("method", "")
self.logger.log(f"HLS registered capability: {method}", logging.INFO)
return
def workspace_configuration_handler(params):
"""Handle workspace/configuration requests from HLS"""
self.logger.log(f"HLS requesting configuration: {params}", logging.INFO)
# Configuration matching VS Code settings and initialization options
haskell_config = {
"formattingProvider": "ormolu",
"checkProject": True,
"plugin": {"importLens": {"codeActionsOn": False, "codeLensOn": False}, "hlint": {"codeActionsOn": False}},
}
# HLS expects array of config items matching requested sections
if isinstance(params, dict) and "items" in params:
result = []
for item in params["items"]:
section = item.get("section", "")
if section == "haskell":
result.append(haskell_config)
else:
result.append({})
self.logger.log(f"Returning configuration: {result}", logging.INFO)
return result
# Fallback: return single config
return [haskell_config]
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_request("workspace/configuration", workspace_configuration_handler)
self.logger.log("Starting Haskell Language Server process", logging.INFO)
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
self.logger.log(
"Sending initialize request from LSP client to LSP server and awaiting response",
logging.INFO,
)
init_response = self.server.send.initialize(initialize_params)
# Log capabilities returned by HLS
capabilities = init_response.get("capabilities", {})
self.logger.log(f"HLS capabilities: {list(capabilities.keys())}", logging.INFO)
self.server.notify.initialized({})
self.completions_available.set()
# Give HLS time to index the project
# HLS can be slow to index, especially on first run
self.logger.log("Waiting for HLS to index project...", logging.INFO)
time.sleep(5)
self.logger.log("Haskell Language Server initialized successfully", logging.INFO)
+18 -11
View File
@@ -7,7 +7,6 @@ import os
import pathlib
import shutil
from time import sleep
from typing import cast
from overrides import override
@@ -28,17 +27,14 @@ class Intelephense(SolidLanguageServer):
Provides PHP specific instantiation of the LanguageServer class using Intelephense.
You can pass the following entries in ls_specific_settings["php"]:
- maxMemory
- maxFileSize
- maxMemory: sets intelephense.maxMemory
- maxFileSize: sets intelephense.files.maxSize
- ignore_vendor: whether or ignore directories named "vendor" (default: true)
"""
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For PHP projects, we should ignore:
# - vendor: third-party dependencies managed by Composer
# - node_modules: if the project has JavaScript components
# - cache: commonly used for caching
return super().is_ignored_dirname(dirname) or dirname in ["node_modules", "vendor", "cache"]
return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames
@classmethod
def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> list[str]:
@@ -102,6 +98,18 @@ class Intelephense(SolidLanguageServer):
)
self.request_id = 0
# For PHP projects, we should ignore:
# - node_modules: if the project has JavaScript components
# - cache: commonly used for caching
# - (configurable) vendor: third-party dependencies <managed by Composer
self._ignored_dirnames = {"node_modules", "cache"}
if self._custom_settings.get("ignore_vendor", True):
self._ignored_dirnames.add("vendor")
self.logger.log(
f"Ignoring the following directories for PHP projects: {', '.join(sorted(self._ignored_dirnames))}",
logging.INFO,
)
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialization params for the Intelephense Language Server.
@@ -132,9 +140,8 @@ class Intelephense(SolidLanguageServer):
if license_key:
initialization_options["licenceKey"] = license_key
custom_intelephense_settings = self._solidlsp_settings.ls_specific_settings.get(self.get_language_enum_instance(), {})
max_memory = custom_intelephense_settings.get("maxMemory")
max_file_size = custom_intelephense_settings.get("maxFileSize")
max_memory = self._custom_settings.get("maxMemory")
max_file_size = self._custom_settings.get("maxFileSize")
if max_memory is not None:
initialization_options["intelephense.maxMemory"] = max_memory
if max_file_size is not None:
+8 -13
View File
@@ -16,7 +16,7 @@ from pathlib import Path
from overrides import override
from solidlsp import ls_types
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
@@ -70,17 +70,13 @@ class NixLanguageServer(SolidLanguageServer):
return symbol
@override
def request_document_symbols(
self, relative_file_path: str, include_body: bool = False
) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]:
"""
Override to extend Nix symbol ranges to include trailing semicolons.
def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
# Override to extend Nix symbol ranges to include trailing semicolons.
# nixd provides expression-level ranges (excluding semicolons) but serena needs
# statement-level ranges (including semicolons) for proper symbol replacement.
nixd provides expression-level ranges (excluding semicolons) but serena needs
statement-level ranges (including semicolons) for proper symbol replacement.
"""
# Get symbols from parent implementation
all_symbols, root_symbols = super().request_document_symbols(relative_file_path, include_body)
document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer)
# Get file content for range extension
file_content = self.language_server.retrieve_full_file_content(relative_file_path)
@@ -97,10 +93,9 @@ class NixLanguageServer(SolidLanguageServer):
return extended
# Apply range extension to all symbols
extended_all_symbols = [extend_symbol_and_children(sym) for sym in all_symbols]
extended_root_symbols = [extend_symbol_and_children(sym) for sym in root_symbols]
extended_root_symbols = [extend_symbol_and_children(sym) for sym in document_symbols.root_symbols]
return extended_all_symbols, extended_root_symbols
return DocumentSymbols(extended_root_symbols)
@override
def is_ignored_dirname(self, dirname: str) -> bool:
+20 -5
View File
@@ -5,7 +5,6 @@ Provides Rust specific instantiation of the LanguageServer class. Contains vario
import logging
import os
import pathlib
import shutil
import subprocess
import threading
from typing import cast
@@ -25,6 +24,22 @@ class RustAnalyzer(SolidLanguageServer):
Provides Rust specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Rust.
"""
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify rust-analyzer stderr output to avoid false-positive errors."""
line_lower = line.lower()
# Known informational/warning messages from rust-analyzer that aren't critical errors
if any(
[
"failed to find any projects in" in line_lower,
"fetchworkspaceerror" in line_lower,
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
@staticmethod
def _get_rustup_version():
"""Get installed rustup version or None if not found."""
@@ -38,17 +53,17 @@ class RustAnalyzer(SolidLanguageServer):
@staticmethod
def _get_rust_analyzer_path():
"""Get rust-analyzer path via rustup or system PATH."""
# First try rustup
"""Get rust-analyzer path via rustup. Returns None if not found."""
try:
# Note: we avoid using system PATH to avoid picking up incorrect aliases
result = subprocess.run(["rustup", "which", "rust-analyzer"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
pass
# Fallback to system PATH
return shutil.which("rust-analyzer")
return None
@staticmethod
def _ensure_rust_analyzer_installed():
@@ -150,6 +150,7 @@ class ScalaLanguageServer(SolidLanguageServer):
"copyWorksheetOutputProvider": False,
"doctorVisibilityProvider": False,
},
"capabilities": {"textDocument": {"documentSymbol": {"hierarchicalDocumentSymbolSupport": True}}},
}
return initialize_params
@@ -26,6 +26,34 @@ class TerraformLS(SolidLanguageServer):
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"]
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify terraform-ls stderr output to avoid false-positive errors."""
line_lower = line.lower()
# File discovery messages that are not actual errors
if any(
[
"discover.go:" in line_lower,
"walker.go:" in line_lower,
"walking of {file://" in line_lower,
"bus: -> discover" in line_lower,
]
):
return logging.DEBUG
# Known informational messages from terraform-ls that contain "error" but aren't errors
# Note: pattern match is flexible to handle file paths between keywords
if any(
[
"loading module metadata returned error:" in line_lower and "state not changed" in line_lower,
"incoming notification for" in line_lower,
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
@staticmethod
def _ensure_tf_command_available(logger: LanguageServerLogger) -> None:
logger.log("Starting terraform version detection...", logging.DEBUG)
@@ -7,7 +7,6 @@ import os
import pathlib
import shutil
import threading
from time import sleep
from typing import Any, cast
from overrides import override
@@ -21,7 +20,6 @@ from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from ..lsp_protocol_handler import lsp_types
from .common import RuntimeDependency, RuntimeDependencyCollection
# Platform-specific imports
@@ -72,6 +70,11 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"coverage",
]
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify typescript-language-server stderr output to avoid false-positive errors."""
return SolidLanguageServer._determine_log_level(line)
@classmethod
def _setup_runtime_dependencies(
cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings
@@ -9,7 +9,6 @@ import os
import pathlib
import shutil
import threading
from time import sleep
from typing import cast
from overrides import override
@@ -22,7 +21,6 @@ from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from ..lsp_protocol_handler import lsp_types
from .common import RuntimeDependency, RuntimeDependencyCollection
@@ -0,0 +1,194 @@
"""
Provides YAML specific instantiation of the LanguageServer class using yaml-language-server.
Contains various configurations and settings specific to YAML files.
"""
import logging
import os
import pathlib
import shutil
import threading
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
class YamlLanguageServer(SolidLanguageServer):
"""
Provides YAML specific instantiation of the LanguageServer class using yaml-language-server.
Contains various configurations and settings specific to YAML files.
"""
@staticmethod
def _determine_log_level(line: str) -> int:
"""Classify yaml-language-server stderr output to avoid false-positive errors."""
line_lower = line.lower()
# Known informational messages from yaml-language-server that aren't critical errors
if any(
[
"cannot find module" in line_lower and "package.json" in line_lower, # Schema resolution - not critical
"no parser" in line_lower, # Parser messages - informational
]
):
return logging.DEBUG
return SolidLanguageServer._determine_log_level(line)
def __init__(
self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings
):
"""
Creates a YamlLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
yaml_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings)
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=yaml_lsp_executable_path, cwd=repository_root_path),
"yaml",
solidlsp_settings,
)
self.server_ready = threading.Event()
@classmethod
def _setup_runtime_dependencies(
cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings
) -> str:
"""
Setup runtime dependencies for YAML Language Server and return the command to start the server.
"""
# Verify both node and npm are installed
is_node_installed = shutil.which("node") is not None
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
is_npm_installed = shutil.which("npm") is not None
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="yaml-language-server",
description="yaml-language-server package (Red Hat)",
command="npm install --prefix ./ yaml-language-server@1.19.2",
platform_id="any",
),
]
)
# Install yaml-language-server if not already installed
yaml_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "yaml-lsp")
yaml_executable_path = os.path.join(yaml_ls_dir, "node_modules", ".bin", "yaml-language-server")
# Handle Windows executable extension
if os.name == "nt":
yaml_executable_path += ".cmd"
if not os.path.exists(yaml_executable_path):
logger.log(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...", logging.INFO)
deps.install(logger, yaml_ls_dir)
logger.log("YAML language server dependencies installed successfully", logging.INFO)
if not os.path.exists(yaml_executable_path):
raise FileNotFoundError(
f"yaml-language-server executable not found at {yaml_executable_path}, something went wrong with the installation."
)
return f"{yaml_executable_path} --stdio"
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the YAML Language Server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"codeAction": {"dynamicRegistration": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
},
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
"initializationOptions": {
"yaml": {
"schemaStore": {"enable": True, "url": "https://www.schemastore.org/api/json/catalog.json"},
"format": {"enable": True},
"validate": True,
"hover": True,
"completion": True,
}
},
}
return initialize_params
def _start_server(self):
"""
Starts the YAML Language Server, waits for the server to be ready and yields the LanguageServer instance.
"""
def register_capability_handler(params):
return
def do_nothing(params):
return
def window_log_message(msg):
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
self.server.on_request("client/registerCapability", register_capability_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)
self.logger.log("Starting YAML server process", logging.INFO)
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
self.logger.log(
"Sending initialize request from LSP client to LSP server and awaiting response",
logging.INFO,
)
init_response = self.server.send.initialize(initialize_params)
self.logger.log(f"Received initialize response from YAML server: {init_response}", logging.DEBUG)
# Verify document symbol support is available
if "documentSymbolProvider" in init_response["capabilities"]:
self.logger.log("YAML server supports document symbols", logging.INFO)
else:
self.logger.log("Warning: YAML server does not report document symbol support", logging.WARNING)
self.server.notify.initialized({})
# YAML language server is ready immediately after initialization
self.logger.log("YAML server initialization complete", logging.INFO)
self.server_ready.set()
self.completions_available.set()
+426 -209
View File
@@ -4,7 +4,6 @@ import json
import logging
import os
import pathlib
import pickle
import shutil
import subprocess
import threading
@@ -18,6 +17,7 @@ from time import sleep
from typing import Self, Union, cast
import pathspec
from sensai.util.pickle import getstate, load_pickle
from serena.text_utils import MatchedConsecutiveLines
from serena.util.file_system import match_path
@@ -31,15 +31,17 @@ from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils
from solidlsp.lsp_protocol_handler import lsp_types
from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes
from solidlsp.lsp_protocol_handler.lsp_constants import LSPConstants
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink, SymbolKind
from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink
from solidlsp.lsp_protocol_handler.server import (
LSPError,
ProcessLaunchInfo,
StringDict,
)
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.cache import load_cache, save_cache
GenericDocumentSymbol = Union[LSPTypes.DocumentSymbol, LSPTypes.SymbolInformation, ls_types.UnifiedSymbolInformation]
log = logging.getLogger(__name__)
@dataclasses.dataclass(kw_only=True)
@@ -77,6 +79,50 @@ class LSPFileBuffer:
def __post_init__(self) -> None:
self.content_hash = hashlib.md5(self.contents.encode("utf-8")).hexdigest()
def split_lines(self) -> list[str]:
"""Splits the contents of the file into lines."""
return self.contents.split("\n")
class DocumentSymbols:
# IMPORTANT: Instances of this class are persisted in the high-level document symbol cache
def __init__(self, root_symbols: list[ls_types.UnifiedSymbolInformation]):
self.root_symbols = root_symbols
self._all_symbols: list[ls_types.UnifiedSymbolInformation] | None = None
def __getstate__(self):
return getstate(DocumentSymbols, self, transient_properties=["_all_symbols"])
def iter_symbols(self) -> Iterator[ls_types.UnifiedSymbolInformation]:
"""
Iterate over all symbols in the document symbol tree.
Yields symbols in a depth-first manner.
"""
if self._all_symbols is not None:
yield from self._all_symbols
return
def traverse(s: ls_types.UnifiedSymbolInformation):
yield s
for child in s.get("children", []):
yield from traverse(child)
for root_symbol in self.root_symbols:
yield from traverse(root_symbol)
def get_all_symbols_and_roots(self) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]:
"""
This function returns all symbols in the document as a flat list and the root symbols.
It exists to facilitate migration from previous versions, where this was the return interface of
the LS method that obtained document symbols.
:return: A tuple containing a list of all symbols in the document and a list of root symbols.
"""
if self._all_symbols is None:
self._all_symbols = list(self.iter_symbols())
return self._all_symbols, self.root_symbols
class SolidLanguageServer(ABC):
"""
@@ -85,6 +131,17 @@ class SolidLanguageServer(ABC):
"""
CACHE_FOLDER_NAME = "cache"
RAW_DOCUMENT_SYMBOLS_CACHE_VERSION = 1
"""
global version identifier for raw symbol caches; an LS-specific version is defined separately and combined with this.
This should be incremented whenever there is a change in the way raw document symbols are stored.
If the result of a language server changes in a way that affects the raw document symbols,
the LS-specific version should be incremented instead.
"""
RAW_DOCUMENT_SYMBOL_CACHE_FILENAME = "raw_document_symbols.pkl"
RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK = "document_symbols_cache_v23-06-25.pkl"
DOCUMENT_SYMBOL_CACHE_VERSION = 3
DOCUMENT_SYMBOL_CACHE_FILENAME = "document_symbols.pkl"
# To be overridden and extended by subclasses
def is_ignored_dirname(self, dirname: str) -> bool:
@@ -94,6 +151,26 @@ class SolidLanguageServer(ABC):
"""
return dirname.startswith(".")
@staticmethod
def _determine_log_level(line: str) -> int:
"""
Classify a stderr line from the language server to determine appropriate logging level.
Language servers may emit informational messages to stderr that contain words like "error"
but are not actual errors. Subclasses can override this method to filter out known
false-positive patterns specific to their language server.
:param line: The stderr line to classify
:return: A logging level (logging.DEBUG, logging.INFO, logging.WARNING, or logging.ERROR)
"""
line_lower = line.lower()
# Default classification: treat lines with "error" or "exception" as ERROR level
if "error" in line_lower or "exception" in line_lower or line.startswith("E["):
return logging.ERROR
else:
return logging.INFO
@classmethod
def get_language_enum_instance(cls) -> Language:
return Language.from_ls_class(cls)
@@ -161,6 +238,7 @@ class SolidLanguageServer(ABC):
process_launch_info: ProcessLaunchInfo,
language_id: str,
solidlsp_settings: SolidLSPSettings,
cache_version_raw_document_symbols: int = 1,
):
"""
Initializes a LanguageServer instance.
@@ -174,8 +252,18 @@ class SolidLanguageServer(ABC):
This parameter is the command to launch the language server process.
The command must pass appropriate flags to the binary, so that it runs in the stdio mode,
as opposed to HTTP, TCP modes supported by some language servers.
:param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming
from this specific language server. This should be incremented by subclasses calling this constructor
whenever the format of the raw document symbols changes (typically because the language server
improves/fixes its output).
"""
self._solidlsp_settings = solidlsp_settings
lang = self.get_language_enum_instance()
self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang)
logger.log(
f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}",
logging.DEBUG,
)
self._encoding = config.encoding
self.logger = logger
self.repository_root_path: str = repository_root_path
@@ -188,14 +276,22 @@ class SolidLanguageServer(ABC):
self.open_file_buffers: dict[str, LSPFileBuffer] = {}
self.language = Language(language_id)
# load cache first to prevent any racing conditions due to asyncio stuff
self._document_symbols_cache: dict[
str, tuple[str, tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]]
] = {}
"""Maps file paths to a tuple of (file_content_hash, result_of_request_document_symbols)"""
self._cache_lock = threading.Lock()
self._cache_has_changed: bool = False
self.load_cache()
# initialise symbol caches
self.cache_dir = (
Path(self.repository_root_path) / self._solidlsp_settings.project_data_relative_path / self.CACHE_FOLDER_NAME / self.language_id
)
self.cache_dir.mkdir(parents=True, exist_ok=True)
# * raw document symbols cache
self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols
self._raw_document_symbols_cache: dict[str, tuple[str, list[GenericDocumentSymbol]]] = {}
"""maps relative file paths to a tuple of (file_content_hash, raw_root_symbols)"""
self._raw_document_symbols_cache_is_modified: bool = False
self._load_raw_document_symbols_cache()
# * high-level document symbols cache
self._document_symbols_cache: dict[str, tuple[str, DocumentSymbols]] = {}
"""maps relative file paths to a tuple of (file_content_hash, document_symbols)"""
self._document_symbols_cache_is_modified: bool = False
self._load_document_symbols_cache()
self.server_started = False
self.completions_available = threading.Event()
@@ -214,6 +310,8 @@ class SolidLanguageServer(ABC):
)
self.server = SolidLanguageServerHandler(
process_launch_info,
language=self.language,
determine_log_level=self._determine_log_level,
logger=logging_fn,
start_independent_lsp_process=config.start_independent_lsp_process,
)
@@ -422,6 +520,20 @@ class SolidLanguageServer(ABC):
)
del self.open_file_buffers[uri]
@contextmanager
def _open_file_context(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> Iterator[LSPFileBuffer]:
"""
Internal context manager to open a file, optionally reusing an existing file buffer.
:param relative_file_path: the relative path of the file to open.
:param file_buffer: an optional existing file buffer to reuse.
"""
if file_buffer is not None:
yield file_buffer
else:
with self.open_file(relative_file_path) as fb:
yield fb
def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position:
"""
Insert text at the given line and column in the given file and return
@@ -831,136 +943,174 @@ class SolidLanguageServer(ABC):
return [json.loads(json_repr) for json_repr in set(json.dumps(item, sort_keys=True) for item in completions_list)]
def request_document_symbols(
self, relative_file_path: str, include_body: bool = False
) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]:
def _request_document_symbols(self, relative_file_path: str, file_data: LSPFileBuffer | None) -> list[GenericDocumentSymbol]:
"""
Raise a [textDocument/documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) request to the Language Server
to find symbols in the given file. Wait for the response and return the result.
Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol)
request to the language server to find symbols in the given file - or returns a cached result if available.
:param relative_file_path: the relative path of the file that has the symbols.
:param file_data: the file data buffer, if already opened. If None, the file will be opened in this method.
:return: the list of root symbols in the file.
"""
def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[GenericDocumentSymbol] | None:
file_hash_and_result = self._raw_document_symbols_cache.get(cache_key)
if file_hash_and_result is not None:
file_hash, result = file_hash_and_result
if file_hash == fd.content_hash:
log.debug("Returning cached raw document symbols for %s", relative_file_path)
return result
else:
log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path)
else:
log.debug("No cache hit for raw document symbols symbols in %s", relative_file_path)
return None
def get_raw_document_symbols(fd: LSPFileBuffer) -> list[GenericDocumentSymbol]:
# check for cached result
cache_key = relative_file_path
response = get_cached_raw_document_symbols(cache_key, fd)
if response is not None:
return response
# no cached result, query language server
self.logger.log(f"Requesting document symbols for {relative_file_path} from the Language Server", logging.DEBUG)
response = self.server.send.document_symbol(
{"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}}
)
# update cache
self._raw_document_symbols_cache[cache_key] = (file_data.content_hash, response)
self._raw_document_symbols_cache_is_modified = True
return response
if file_data is not None:
return get_raw_document_symbols(file_data)
else:
with self.open_file(relative_file_path) as opened_file_data:
return get_raw_document_symbols(opened_file_data)
def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols:
"""
Retrieves the collection of symbols in the given file
:param relative_file_path: The relative path of the file that has the symbols
:param include_body: whether to include the body of the symbols in the result.
:return: A list of symbols in the file, and a list of root symbols that represent the tree structure of the symbols.
All symbols will have a location, a children, and a parent attribute,
:param file_buffer: an optional file buffer if the file is already opened.
:return: the collection of symbols in the file.
All contained symbols will have a location, children, and a parent attribute,
where the parent attribute is None for root symbols.
Note that this is slightly different from the call to request_full_symbol_tree,
where the parent attribute will be the file symbol which in turn may have a package symbol as parent.
If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead.
"""
# TODO: it's kinda dumb to not use the cache if include_body is False after include_body was True once
# Should be fixed in the future, it's a small performance optimization
cache_key = f"{relative_file_path}-{include_body}"
with self.open_file(relative_file_path) as file_data:
with self._cache_lock:
file_hash_and_result = self._document_symbols_cache.get(cache_key)
if file_hash_and_result is not None:
file_hash, result = file_hash_and_result
if file_hash == file_data.content_hash:
self.logger.log(f"Returning cached document symbols for {relative_file_path}", logging.DEBUG)
return result
else:
self.logger.log(f"Content for {relative_file_path} has changed. Will overwrite in-memory cache", logging.DEBUG)
with self._open_file_context(relative_file_path, file_buffer) as file_data:
# check if the desired result is cached
cache_key = relative_file_path
file_hash_and_result = self._document_symbols_cache.get(cache_key)
if file_hash_and_result is not None:
file_hash, document_symbols = file_hash_and_result
if file_hash == file_data.content_hash:
log.debug("Returning cached document symbols for %s", relative_file_path)
return document_symbols
else:
self.logger.log(f"No cache hit for symbols with {include_body=} in {relative_file_path}", logging.DEBUG)
log.debug("Cached document symbol content for %s has changed", relative_file_path)
else:
log.debug("No cache hit for document symbols in %s", relative_file_path)
self.logger.log(f"Requesting document symbols for {relative_file_path} from the Language Server", logging.DEBUG)
response = self.server.send.document_symbol(
{"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}}
)
if response is None:
self.logger.log(
# no cached result: request the root symbols from the language server
root_symbols = self._request_document_symbols(relative_file_path, file_data)
if root_symbols is None:
log.warning(
f"Received None response from the Language Server for document symbols in {relative_file_path}. "
f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. "
f"Returning empty list",
logging.WARNING,
)
return [], []
assert isinstance(response, list), f"Unexpected response from Language Server: {response}"
self.logger.log(
f"Received {len(response)} document symbols for {relative_file_path} from the Language Server",
logging.DEBUG,
)
return DocumentSymbols([])
def turn_item_into_symbol_with_children(item: GenericDocumentSymbol) -> None:
item = cast(ls_types.UnifiedSymbolInformation, item)
absolute_path = os.path.join(self.repository_root_path, relative_file_path)
assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}"
log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path)
# handle missing entries in location
if "location" not in item:
uri = pathlib.Path(absolute_path).as_uri()
assert "range" in item
tree_location = ls_types.Location(
uri=uri,
range=item["range"],
absolutePath=absolute_path,
relativePath=relative_file_path,
)
item["location"] = tree_location
location = item["location"]
if "absolutePath" not in location:
location["absolutePath"] = absolute_path # type: ignore
if "relativePath" not in location:
location["relativePath"] = relative_file_path # type: ignore
if include_body:
item["body"] = self.retrieve_symbol_body(item)
# handle missing selectionRange
if "selectionRange" not in item:
if "range" in item:
item["selectionRange"] = item["range"]
else:
item["selectionRange"] = item["location"]["range"]
children = item.get(LSPConstants.CHILDREN, []) # type: ignore
for child in children: # type: ignore
child["parent"] = item
item[LSPConstants.CHILDREN] = children # type: ignore
file_lines = file_data.split_lines()
flat_all_symbol_list: list[ls_types.UnifiedSymbolInformation] = []
root_nodes: list[ls_types.UnifiedSymbolInformation] = []
for root_item in response:
if "range" not in root_item and "location" not in root_item:
if root_item["kind"] in [SymbolKind.File, SymbolKind.Module]: # type: ignore
...
def convert_to_unified_symbol(original_symbol_dict: GenericDocumentSymbol) -> ls_types.UnifiedSymbolInformation:
"""
Converts the given symbol dictionary to the unified representation, ensuring
that all required fields are present (except 'children' which is handled separately).
# mutation is more convenient than creating a new dict,
# so we cast and rename the var after the mutating call to turn_item_into_symbol_with_children
# which turned and item into a "symbol"
turn_item_into_symbol_with_children(root_item)
root_symbol = cast(ls_types.UnifiedSymbolInformation, root_item)
root_symbol["parent"] = None
:param original_symbol_dict: the item to augment
:return: the augmented item (new object)
"""
# noinspection PyInvalidCast
item = cast(ls_types.UnifiedSymbolInformation, dict(original_symbol_dict))
absolute_path = os.path.join(self.repository_root_path, relative_file_path)
root_nodes.append(root_symbol)
assert isinstance(root_symbol, dict)
assert LSPConstants.NAME in root_symbol
assert LSPConstants.KIND in root_symbol
# handle missing location and path entries
if "location" not in item:
uri = pathlib.Path(absolute_path).as_uri()
assert "range" in item
tree_location = ls_types.Location(
uri=uri,
range=item["range"],
absolutePath=absolute_path,
relativePath=relative_file_path,
)
item["location"] = tree_location
location = item["location"]
if "absolutePath" not in location:
location["absolutePath"] = absolute_path
if "relativePath" not in location:
location["relativePath"] = relative_file_path
if LSPConstants.CHILDREN in root_symbol:
# TODO: l_tree should be a list of TreeRepr. Define the following function to return TreeRepr as well
if "body" not in item:
item["body"] = self.retrieve_symbol_body(item, file_lines=file_lines)
def visit_tree_nodes_and_build_tree_repr(node: GenericDocumentSymbol) -> list[ls_types.UnifiedSymbolInformation]:
node = cast(ls_types.UnifiedSymbolInformation, node)
l: list[ls_types.UnifiedSymbolInformation] = []
turn_item_into_symbol_with_children(node)
assert LSPConstants.CHILDREN in node
children = node[LSPConstants.CHILDREN] # type: ignore
l.append(node)
for child in children:
l.extend(visit_tree_nodes_and_build_tree_repr(child))
return l
# handle missing selectionRange
if "selectionRange" not in item:
if "range" in item:
item["selectionRange"] = item["range"]
else:
item["selectionRange"] = item["location"]["range"]
flat_all_symbol_list.extend(visit_tree_nodes_and_build_tree_repr(root_symbol))
else:
flat_all_symbol_list.append(ls_types.UnifiedSymbolInformation(**root_symbol))
return item
result = flat_all_symbol_list, root_nodes
self.logger.log(f"Caching document symbols for {relative_file_path}", logging.DEBUG)
with self._cache_lock:
self._document_symbols_cache[cache_key] = (file_data.content_hash, result)
self._cache_has_changed = True
return result
def convert_symbols_with_common_parent(
symbols: list[GenericDocumentSymbol], parent: ls_types.UnifiedSymbolInformation | None
) -> list[ls_types.UnifiedSymbolInformation]:
"""
Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships,
adding overload indices for symbols with the same name under the same parent.
"""
total_name_counts = defaultdict(lambda: 0)
for symbol in symbols:
total_name_counts[symbol["name"]] += 1
name_counts = defaultdict(lambda: 0)
unified_symbols = []
for symbol in symbols:
usymbol = convert_to_unified_symbol(symbol)
if total_name_counts[usymbol["name"]] > 1:
usymbol["overload_idx"] = name_counts[usymbol["name"]]
name_counts[usymbol["name"]] += 1
usymbol["parent"] = parent
if "children" in usymbol:
usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol)
else:
usymbol["children"] = []
unified_symbols.append(usymbol)
return unified_symbols
def request_full_symbol_tree(
self, within_relative_path: str | None = None, include_body: bool = False
) -> list[ls_types.UnifiedSymbolInformation]:
unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None)
document_symbols = DocumentSymbols(unified_root_symbols)
# update cache
log.debug("Updating cached document symbols for %s", relative_file_path)
self._document_symbols_cache[cache_key] = (file_data.content_hash, document_symbols)
self._document_symbols_cache_is_modified = True
return document_symbols
def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[ls_types.UnifiedSymbolInformation]:
"""
Will go through all files in the project or within a relative path and build a tree of symbols.
Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search.
@@ -975,8 +1125,6 @@ class SolidLanguageServer(ABC):
:param within_relative_path: pass a relative path to only consider symbols within this path.
If a file is passed, only the symbols within this file will be considered.
If a directory is passed, all files within this directory will be considered.
:param include_body: whether to include the body of the symbols in the result.
:return: A list of root symbols representing the top-level packages/modules in the project.
"""
if within_relative_path is not None:
@@ -985,13 +1133,10 @@ class SolidLanguageServer(ABC):
raise FileNotFoundError(f"File or directory not found: {within_abs_path}")
if os.path.isfile(within_abs_path):
if self.is_ignored_path(within_relative_path):
self.logger.log(
f"You passed a file explicitly, but it is ignored. This is probably an error. File: {within_relative_path}",
logging.ERROR,
)
log.error("You passed a file explicitly, but it is ignored. This is probably an error. File: %s", within_relative_path)
return []
else:
_, root_nodes = self.request_document_symbols(within_relative_path, include_body=include_body)
root_nodes = self.request_document_symbols(within_relative_path).root_symbols
return root_nodes
# Helper function to recursively process directories
@@ -1000,7 +1145,7 @@ class SolidLanguageServer(ABC):
abs_dir_path = os.path.realpath(abs_dir_path)
if self.is_ignored_path(str(Path(abs_dir_path).relative_to(self.repository_root_path))):
self.logger.log(f"Skipping directory: {rel_dir_path}\n(because it should be ignored)", logging.DEBUG)
log.debug("Skipping directory: %s (because it should be ignored)", rel_dir_path)
return []
result = []
@@ -1033,14 +1178,16 @@ class SolidLanguageServer(ABC):
)
except ValueError as e:
# Typically happens when the path is not under the repository root (e.g., symlink pointing outside)
self.logger.log(
f"Skipping path {contained_dir_or_file_abs_path}; likely outside of the repository root {self.repository_root_path} [cause: {e}]",
logging.WARNING,
log.warning(
"Skipping path %s; likely outside of the repository root %s [cause: %s]",
contained_dir_or_file_abs_path,
self.repository_root_path,
e,
)
continue
if self.is_ignored_path(contained_dir_or_file_rel_path):
self.logger.log(f"Skipping item: {contained_dir_or_file_rel_path}\n(because it should be ignored)", logging.DEBUG)
log.debug("Skipping item: %s (because it should be ignored)", contained_dir_or_file_rel_path)
continue
if os.path.isdir(contained_dir_or_file_abs_path):
@@ -1050,28 +1197,28 @@ class SolidLanguageServer(ABC):
child["parent"] = package_symbol
elif os.path.isfile(contained_dir_or_file_abs_path):
_, file_root_nodes = self.request_document_symbols(contained_dir_or_file_rel_path, include_body=include_body)
with self._open_file_context(contained_dir_or_file_rel_path) as file_data:
document_symbols = self.request_document_symbols(contained_dir_or_file_rel_path, file_data)
file_root_nodes = document_symbols.root_symbols
# Create file symbol, link with children
file_rel_path = str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path))
with self.open_file(file_rel_path) as file_data:
fileRange = self._get_range_from_file_content(file_data.contents)
file_symbol = ls_types.UnifiedSymbolInformation( # type: ignore
name=os.path.splitext(contained_dir_or_file_name)[0],
kind=ls_types.SymbolKind.File,
range=fileRange,
selectionRange=fileRange,
location=ls_types.Location(
uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()),
range=fileRange,
absolutePath=str(contained_dir_or_file_abs_path),
relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)),
),
children=file_root_nodes,
parent=package_symbol,
)
for child in file_root_nodes:
child["parent"] = file_symbol
# Create file symbol, link with children
file_range = self._get_range_from_file_content(file_data.contents)
file_symbol = ls_types.UnifiedSymbolInformation( # type: ignore
name=os.path.splitext(contained_dir_or_file_name)[0],
kind=ls_types.SymbolKind.File,
range=file_range,
selectionRange=file_range,
location=ls_types.Location(
uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()),
range=file_range,
absolutePath=str(contained_dir_or_file_abs_path),
relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)),
),
children=file_root_nodes,
parent=package_symbol,
)
for child in file_root_nodes:
child["parent"] = file_symbol
# Link file symbol with package
package_symbol["children"].append(file_symbol)
@@ -1159,8 +1306,7 @@ class SolidLanguageServer(ABC):
"""
:return: the top-level symbols in the given file.
"""
_, document_roots = self.request_document_symbols(relative_file_path)
return document_roots
return self.request_document_symbols(relative_file_path).root_symbols
def request_overview(self, within_relative_path: str) -> dict[str, list[UnifiedSymbolInformation]]:
"""
@@ -1208,7 +1354,12 @@ class SolidLanguageServer(ABC):
return ls_types.Hover(**response) # type: ignore
def retrieve_symbol_body(self, symbol: ls_types.UnifiedSymbolInformation | LSPTypes.DocumentSymbol | LSPTypes.SymbolInformation) -> str:
def retrieve_symbol_body(
self,
symbol: ls_types.UnifiedSymbolInformation | LSPTypes.DocumentSymbol | LSPTypes.SymbolInformation,
file_lines: list[str] | None = None,
file_buffer: LSPFileBuffer | None = None,
) -> str:
"""
Load the body of the given symbol. If the body is already contained in the symbol, just return it.
"""
@@ -1217,12 +1368,13 @@ class SolidLanguageServer(ABC):
return str(existing_body)
assert "location" in symbol
symbol_start_line = symbol["location"]["range"]["start"]["line"] # type: ignore
symbol_end_line = symbol["location"]["range"]["end"]["line"] # type: ignore
assert "relativePath" in symbol["location"] # type: ignore
symbol_file = self.retrieve_full_file_content(symbol["location"]["relativePath"]) # type: ignore
symbol_lines = symbol_file.split("\n")
symbol_body = "\n".join(symbol_lines[symbol_start_line : symbol_end_line + 1])
symbol_start_line = symbol["location"]["range"]["start"]["line"]
symbol_end_line = symbol["location"]["range"]["end"]["line"]
assert "relativePath" in symbol["location"]
if file_lines is None:
with self._open_file_context(symbol["location"]["relativePath"], file_buffer) as f:
file_lines = f.split_lines()
symbol_body = "\n".join(file_lines[symbol_start_line : symbol_end_line + 1])
# remove leading indentation
symbol_start_column = symbol["location"]["range"]["start"]["character"] # type: ignore
@@ -1298,9 +1450,8 @@ class SolidLanguageServer(ABC):
ref_text = file_data.contents.split("\n")[ref_line]
if "." in ref_text:
containing_symbol_name = ref_text.split(".")[0]
assert ref_path is not None
all_symbols, _ = self.request_document_symbols(ref_path)
for symbol in all_symbols:
document_symbols = self.request_document_symbols(ref_path)
for symbol in document_symbols.iter_symbols():
if symbol["name"] == containing_symbol_name and symbol["kind"] == ls_types.SymbolKind.Variable:
containing_symbol = copy(symbol)
containing_symbol["location"] = ref
@@ -1421,12 +1572,12 @@ class SolidLanguageServer(ABC):
)
return None
symbols, _ = self.request_document_symbols(relative_file_path)
document_symbols = self.request_document_symbols(relative_file_path)
# make jedi and pyright api compatible
# the former has no location, the later has no range
# we will just always add location of the desired format to all symbols
for symbol in symbols:
for symbol in document_symbols.iter_symbols():
if "location" not in symbol:
range = symbol["range"]
location = ls_types.Location(
@@ -1464,10 +1615,10 @@ class SolidLanguageServer(ABC):
# Only consider containers that are not one-liners (otherwise we may get imports)
candidate_containers = [
s
for s in symbols
for s in document_symbols.iter_symbols()
if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"]
]
var_containers = [s for s in symbols if s["kind"] == ls_types.SymbolKind.Variable]
var_containers = [s for s in document_symbols.iter_symbols() if s["kind"] == ls_types.SymbolKind.Variable]
candidate_containers.extend(var_containers)
if not candidate_containers:
@@ -1555,56 +1706,113 @@ class SolidLanguageServer(ABC):
return defining_symbol
@property
def cache_path(self) -> Path:
"""
The path to the cache file for the document symbols.
"""
return (
Path(self.repository_root_path)
/ self._solidlsp_settings.project_data_relative_path
/ self.CACHE_FOLDER_NAME
/ self.language_id
/ "document_symbols_cache_v23-06-25.pkl"
)
def _save_raw_document_symbols_cache(self):
cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME
def save_cache(self) -> None:
with self._cache_lock:
if not self._cache_has_changed:
self.logger.log("No changes to document symbols cache, skipping save", logging.DEBUG)
return
self.logger.log(f"Saving updated document symbols cache to {self.cache_path}", logging.INFO)
self.cache_path.parent.mkdir(parents=True, exist_ok=True)
try:
with open(self.cache_path, "wb") as f:
pickle.dump(self._document_symbols_cache, f)
self._cache_has_changed = False
except Exception as e:
self.logger.log(
f"Failed to save document symbols cache to {self.cache_path}: {e}. "
"Note: this may have resulted in a corrupted cache file.",
logging.ERROR,
)
def load_cache(self) -> None:
if not self.cache_path.exists():
if not self._raw_document_symbols_cache_is_modified:
log.debug("No changes to raw document symbols cache, skipping save")
return
with self._cache_lock:
self.logger.log(f"Loading document symbols cache from {self.cache_path}", logging.INFO)
log.info("Saving updated raw document symbols cache to %s", cache_file)
try:
save_cache(str(cache_file), self._raw_document_symbols_cache_version(), self._raw_document_symbols_cache)
self._raw_document_symbols_cache_is_modified = False
except Exception as e:
log.error(
"Failed to save raw document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.",
cache_file,
e,
)
def _raw_document_symbols_cache_version(self):
return (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version)
def _load_raw_document_symbols_cache(self):
cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME
if not cache_file.exists():
# check for legacy cache to load to migrate
legacy_cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK
if legacy_cache_file.exists():
try:
legacy_cache: dict[
str, tuple[str, tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]]
] = load_pickle(legacy_cache_file)
log.info("Migrating legacy document symbols cache with %d entries", len(legacy_cache))
num_symbols_migrated = 0
migrated_cache = {}
for cache_key, (file_hash, (all_symbols, root_symbols)) in legacy_cache.items():
if cache_key.endswith("-True"): # include_body=True
new_cache_key = cache_key[:-5]
migrated_cache[new_cache_key] = (file_hash, root_symbols)
num_symbols_migrated += len(all_symbols)
log.info("Migrated %d document symbols from legacy cache", num_symbols_migrated)
self._raw_document_symbols_cache = migrated_cache
self._raw_document_symbols_cache_is_modified = True
self._save_raw_document_symbols_cache()
legacy_cache_file.unlink()
return
except Exception as e:
log.error("Error during cache migration: %s", e)
return
# load existing cache (if any)
if cache_file.exists():
log.info("Loading document symbols cache from %s", cache_file)
try:
with open(self.cache_path, "rb") as f:
self._document_symbols_cache = pickle.load(f)
self.logger.log(f"Loaded {len(self._document_symbols_cache)} document symbols from cache.", logging.INFO)
saved_cache = load_cache(str(cache_file), self._raw_document_symbols_cache_version())
if saved_cache is not None:
self._raw_document_symbols_cache = saved_cache
self.logger.log(
f"Loaded {len(self._raw_document_symbols_cache)} entries from raw document symbols cache.", logging.INFO
)
except Exception as e:
# cache often becomes corrupt, so just skip loading it
self.logger.log(
f"Failed to load document symbols cache from {self.cache_path}: {e}. Possible cause: the cache file is corrupted. "
"Check for any errors related to saving the cache in the logs.",
logging.ERROR,
# cache can become corrupt, so just skip loading it
log.warning(
"Failed to load raw document symbols cache from %s (%s); Ignoring cache.",
cache_file,
e,
)
def _save_document_symbols_cache(self):
cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME
if not self._document_symbols_cache_is_modified:
log.debug("No changes to document symbols cache, skipping save")
return
log.info("Saving updated document symbols cache to %s", cache_file)
try:
save_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION, self._document_symbols_cache)
self._document_symbols_cache_is_modified = False
except Exception as e:
log.error(
"Failed to save document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.",
cache_file,
e,
)
def _load_document_symbols_cache(self):
cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME
if cache_file.exists():
log.info("Loading document symbols cache from %s", cache_file)
try:
saved_cache = load_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION)
if saved_cache is not None:
self._document_symbols_cache = saved_cache
self.logger.log(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.", logging.INFO)
except Exception as e:
# cache can become corrupt, so just skip loading it
log.warning(
"Failed to load document symbols cache from %s (%s); Ignoring cache.",
cache_file,
e,
)
def save_cache(self):
self._save_raw_document_symbols_cache()
self._save_document_symbols_cache()
def request_workspace_symbol(self, query: str) -> list[ls_types.UnifiedSymbolInformation] | None:
"""
Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server
@@ -1692,7 +1900,16 @@ class SolidLanguageServer(ABC):
return self
def stop(self, shutdown_timeout: float = 2.0) -> None:
self._shutdown(timeout=shutdown_timeout)
"""
Stops the language server process.
This function never raises an exception (any exceptions during shutdown are logged).
:param shutdown_timeout: time, in seconds, to wait for the server to shutdown gracefully before killing it
"""
try:
self._shutdown(timeout=shutdown_timeout)
except Exception as e:
self.logger.log(f"Exception while shutting down language server: {e}", logging.WARNING)
@property
def language_server(self) -> Self:
+27 -1
View File
@@ -58,6 +58,8 @@ class Language(str, Enum):
REGO = "rego"
SCALA = "scala"
JULIA = "julia"
FORTRAN = "fortran"
HASKELL = "haskell"
# Experimental or deprecated Language Servers
TYPESCRIPT_VTS = "typescript_vts"
"""Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls"""
@@ -76,6 +78,10 @@ class Language(str, Enum):
Must be explicitly specified as the main language, not auto-detected.
This is an edge case primarily useful when working on documentation-heavy projects.
"""
YAML = "yaml"
"""YAML language server (experimental).
Must be explicitly specified as the main language, not auto-detected.
"""
@classmethod
def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]:
@@ -87,7 +93,7 @@ class Language(str, Enum):
"""
Check if the language server is experimental or deprecated.
"""
return self in {self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.MARKDOWN}
return self in {self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.MARKDOWN, self.YAML}
def __str__(self) -> str:
return self.value
@@ -140,6 +146,8 @@ class Language(str, Enum):
return FilenameMatcher("*.swift")
case self.BASH:
return FilenameMatcher("*.sh", "*.bash")
case self.YAML:
return FilenameMatcher("*.yaml", "*.yml")
case self.ZIG:
return FilenameMatcher("*.zig", "*.zon")
case self.LUA:
@@ -158,6 +166,12 @@ class Language(str, Enum):
return FilenameMatcher("*.scala", "*.sbt")
case self.JULIA:
return FilenameMatcher("*.jl")
case self.FORTRAN:
return FilenameMatcher(
"*.f90", "*.F90", "*.f95", "*.F95", "*.f03", "*.F03", "*.f08", "*.F08", "*.f", "*.F", "*.for", "*.FOR", "*.fpp", "*.FPP"
)
case self.HASKELL:
return FilenameMatcher("*.hs", "*.lhs")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -251,6 +265,10 @@ class Language(str, Enum):
from solidlsp.language_servers.bash_language_server import BashLanguageServer
return BashLanguageServer
case self.YAML:
from solidlsp.language_servers.yaml_language_server import YamlLanguageServer
return YamlLanguageServer
case self.ZIG:
from solidlsp.language_servers.zls import ZigLanguageServer
@@ -291,6 +309,14 @@ class Language(str, Enum):
from solidlsp.language_servers.julia_server import JuliaLanguageServer
return JuliaLanguageServer
case self.FORTRAN:
from solidlsp.language_servers.fortran_language_server import FortranLanguageServer
return FortranLanguageServer
case self.HASKELL:
from solidlsp.language_servers.haskell_language_server import HaskellLanguageServer
return HaskellLanguageServer
case _:
raise ValueError(f"Unhandled language: {self}")
+12
View File
@@ -2,6 +2,8 @@
This module contains the exceptions raised by the framework.
"""
from solidlsp.ls_config import Language
class SolidLSPException(Exception):
def __init__(self, message: str, cause: Exception | None = None) -> None:
@@ -26,6 +28,16 @@ class SolidLSPException(Exception):
return isinstance(self.cause, LanguageServerTerminatedException)
def get_affected_language(self) -> Language | None:
"""
:return: the affected language for the case where the exception is caused by the language server having terminated
"""
from .ls_handler import LanguageServerTerminatedException
if isinstance(self.cause, LanguageServerTerminatedException):
return self.cause.language
return None
def __str__(self) -> str:
"""
Returns a string representation of the exception.
+20 -17
View File
@@ -14,6 +14,7 @@ from typing import Any
import psutil
from sensai.util.string import ToStringMixin
from solidlsp.ls_config import Language
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_request import LanguageServerRequest
from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification
@@ -32,7 +33,7 @@ from solidlsp.lsp_protocol_handler.server import (
make_request,
make_response,
)
from solidlsp.util.subprocess_util import subprocess_kwargs
from solidlsp.util.subprocess_util import quote_arg, subprocess_kwargs
log = logging.getLogger(__name__)
@@ -42,9 +43,10 @@ class LanguageServerTerminatedException(Exception):
Exception raised when the language server process has terminated unexpectedly.
"""
def __init__(self, message: str, cause: Exception | None = None) -> None:
def __init__(self, message: str, language: Language, cause: Exception | None = None) -> None:
super().__init__(message)
self.message = message
self.language = language
self.cause = cause
def __str__(self) -> str:
@@ -52,7 +54,6 @@ class LanguageServerTerminatedException(Exception):
class Request(ToStringMixin):
@dataclass
class Result:
payload: PayloadLike | None = None
@@ -132,10 +133,14 @@ class SolidLanguageServerHandler:
def __init__(
self,
process_launch_info: ProcessLaunchInfo,
language: Language,
determine_log_level: Callable[[str], int],
logger: Callable[[str, str, StringDict | str], None] | None = None,
start_independent_lsp_process: bool = True,
request_timeout: float | None = None,
) -> None:
self.language = language
self._determine_log_level = determine_log_level
self.send = LanguageServerRequest(self)
self.notify = LspNotification(self.send_notification)
@@ -185,7 +190,7 @@ class SolidLanguageServerHandler:
if not isinstance(cmd, str) and not is_windows:
# Since we are using the shell, we need to convert the command list to a single string
# on Linux/macOS
cmd = " ".join(cmd)
cmd = " ".join(map(quote_arg, cmd))
log.info("Starting language server process via command: %s", self.process_launch_info.cmd)
kwargs = subprocess_kwargs()
kwargs["start_new_session"] = self.start_independent_lsp_process
@@ -211,12 +216,12 @@ class SolidLanguageServerHandler:
# start threads to read stdout and stderr of the process
threading.Thread(
target=self._read_ls_process_stdout,
name="LSP-stdout-reader",
name=f"LSP-stdout-reader:{self.language.value}",
daemon=True,
).start()
threading.Thread(
target=self._read_ls_process_stderr,
name="LSP-stderr-reader",
name=f"LSP-stderr-reader:{self.language.value}",
daemon=True,
).start()
@@ -310,8 +315,7 @@ class SolidLanguageServerHandler:
if self.logger is not None:
self.logger("client", "logger", message)
@staticmethod
def _read_bytes_from_process(process: subprocess.Popen[bytes], stream: Any, num_bytes: int) -> bytes:
def _read_bytes_from_process(self, process, stream, num_bytes):
"""Read exactly num_bytes from process stdout"""
data = b""
while len(data) < num_bytes:
@@ -319,7 +323,8 @@ class SolidLanguageServerHandler:
if not chunk:
if process.poll() is not None:
raise LanguageServerTerminatedException(
f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)"
f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)",
language=self.language,
)
# Process still running but no data available yet, retry after a short delay
time.sleep(0.01)
@@ -356,13 +361,15 @@ class SolidLanguageServerHandler:
except LanguageServerTerminatedException as e:
exception = e
except (BrokenPipeError, ConnectionResetError) as e:
exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", cause=e)
exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", self.language, cause=e)
except Exception as e:
exception = LanguageServerTerminatedException("Unexpected error while reading stdout from language server process", cause=e)
exception = LanguageServerTerminatedException(
"Unexpected error while reading stdout from language server process", self.language, cause=e
)
log.info("Language server stdout reader thread has terminated")
if not self._is_shutting_down:
if exception is None:
exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly")
exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly", self.language)
log.error(str(exception))
self._cancel_pending_requests(exception)
@@ -379,11 +386,7 @@ class SolidLanguageServerHandler:
if not line:
continue
line = line.decode(ENCODING, errors="replace")
line_lower = line.lower()
if "error" in line_lower or "exception" in line_lower or line.startswith("E["):
level = logging.ERROR
else:
level = logging.INFO
level = self._determine_log_level(line)
log.log(level, line)
except Exception as e:
log.error("Error while reading stderr from language server process: %s", e, exc_info=e)
+8
View File
@@ -243,6 +243,14 @@ class UnifiedSymbolInformation(TypedDict):
All symbols except the root packages will have a parent.
"""
overload_idx: NotRequired[int]
"""
The overload index of the symbol, if applicable. If a symbol does not have overloads, this field is omitted.
If the symbol is an overloaded function or method (same symbol name with the same parent),
this index indicates which overload it is. The index is 0-based.
Added for Serena, not part of the LSP.
"""
class MarkupKind(Enum):
"""Describes the content type that a client supports in various
+35 -1
View File
@@ -2,14 +2,19 @@
Defines settings for Solid-LSP
"""
import logging
import os
import pathlib
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from sensai.util.string import ToStringMixin
if TYPE_CHECKING:
from solidlsp.ls_config import Language
log = logging.getLogger(__name__)
@dataclass
class SolidLSPSettings:
@@ -23,7 +28,7 @@ class SolidLSPSettings:
For instance, if this is ".solidlsp" and the project is located at "/home/user/myproject",
then Solid-LSP will store project-specific data in "/home/user/myproject/.solidlsp".
"""
ls_specific_settings: dict["Language", Any] = field(default_factory=dict)
ls_specific_settings: dict["Language", dict[str, Any]] = field(default_factory=dict)
"""
Advanced configuration option allowing to configure language server implementation specific options.
Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available.
@@ -37,3 +42,32 @@ class SolidLSPSettings:
@property
def ls_resources_dir(self) -> str:
return os.path.join(str(self.solidlsp_dir), "language_servers", "static")
class CustomLSSettings(ToStringMixin):
def __init__(self, settings: dict[str, Any] | None) -> None:
self.settings = settings or {}
def get(self, key: str, default_value: Any = None) -> Any:
"""
Returns the custom setting for the given key or the default value if not set.
If a custom value is set for the given key, the retrieval is logged.
:param key: the key
:param default_value: the default value to use if no custom value is set
:return: the value
"""
if key in self.settings:
value = self.settings[key]
log.info("Using custom LS setting %s for key '%s'", value, key)
else:
value = default_value
return value
def get_ls_specific_settings(self, language: "Language") -> CustomLSSettings:
"""
Get the language server specific settings for the given language.
:param language: The programming language.
:return: A dictionary of settings for the language server.
"""
return self.CustomLSSettings(self.ls_specific_settings.get(language))
+23
View File
@@ -0,0 +1,23 @@
import logging
from typing import Any, Optional
from sensai.util.pickle import dump_pickle, load_pickle
log = logging.getLogger(__name__)
def load_cache(path: str, version: Any) -> Optional[Any]:
data = load_pickle(path)
if not isinstance(data, dict) or "__cache_version" not in data:
log.info("Cache is outdated (expected version %s). Ignoring cache at %s", version, path)
return None
saved_version = data["__cache_version"]
if saved_version != version:
log.info("Cache is outdated (expected version %s, got %s). Ignoring cache at %s", version, saved_version, path)
return None
return data["obj"]
def save_cache(path: str, version: Any, obj: Any) -> None:
data = {"__cache_version": version, "obj": obj}
dump_pickle(data, path)
+9
View File
@@ -11,3 +11,12 @@ def subprocess_kwargs():
if platform.system() == "Windows":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW # type: ignore
return kwargs
def quote_arg(arg: str) -> str:
"""
Adds quotes around an argument if it contains spaces.
"""
if " " not in arg:
return arg
return f'"{arg}"'
@@ -0,0 +1,16 @@
program test_program
use math_utils
implicit none
real :: result
! Test addition
result = add_numbers(5.0, 3.0)
call print_result(result)
! Test multiplication
result = multiply_numbers(4.0, 2.0)
call print_result(result)
print *, "All tests completed"
end program test_program
@@ -0,0 +1,46 @@
module geometry_types
implicit none
! Simple type definition
type Point2D
real :: x, y
end type Point2D
! Type with double colon syntax
type :: Circle
real :: radius
type(Point2D) :: center
end type Circle
! Type with extends (inheritance)
type, extends(Point2D) :: Point3D
real :: z
end type Point3D
! Named interface
interface distance
module procedure distance_2d, distance_3d
end interface distance
contains
function distance_2d(p1, p2) result(dist)
type(Point2D), intent(in) :: p1, p2
real :: dist
dist = sqrt((p2%x - p1%x)**2 + (p2%y - p1%y)**2)
end function distance_2d
function distance_3d(p1, p2) result(dist)
type(Point3D), intent(in) :: p1, p2
real :: dist
dist = sqrt((p2%x - p1%x)**2 + (p2%y - p1%y)**2 + (p2%z - p1%z)**2)
end function distance_3d
function circle_area(c) result(area)
type(Circle), intent(in) :: c
real :: area
real, parameter :: pi = 3.14159265359
area = pi * c%radius**2
end function circle_area
end module geometry_types
@@ -0,0 +1,22 @@
module math_utils
implicit none
contains
function add_numbers(a, b) result(sum)
real, intent(in) :: a, b
real :: sum
sum = a + b
end function add_numbers
function multiply_numbers(x, y) result(product)
real, intent(in) :: x, y
real :: product
product = x * y
end function multiply_numbers
subroutine print_result(value)
real, intent(in) :: value
print *, "Result is:", value
end subroutine print_result
end module math_utils
@@ -0,0 +1,26 @@
module Main (main) where
import Calculator
import Helper
main :: IO ()
main = do
let calc = Calculator "TestCalc" 1
putStrLn $ "Using " ++ calcName calc ++ " version " ++ show (calcVersion calc)
-- Test add function (cross-file reference)
let result1 = add 5 3
putStrLn $ "5 + 3 = " ++ show result1
-- Test subtract (uses validateNumber from Helper)
let result2 = Calculator.subtract 10 4
putStrLn $ "10 - 4 = " ++ show result2
-- Test calculate function
case calculate calc "multiply" 6 7 of
Just result -> putStrLn $ "6 * 7 = " ++ show result
Nothing -> putStrLn "Calculation failed"
-- Test helper functions directly
putStrLn $ "Is 5 positive? " ++ show (isPositive 5)
putStrLn $ "Absolute of -10: " ++ show (absolute (-10))
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more