Merge branch 'main' into feature/toml-support

# Conflicts:
#	pyproject.toml
This commit is contained in:
Michael Panchenko committed 2025-12-12 10:17:30 +07:00
commit fdd89d0648
109 files changed
+9354 -852

No files matched your search

+1 -14
View File
@@ -44,26 +44,13 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push production image
- name: Build and push image from main
uses: docker/build-push-action@v5
with:
context: .
target: production
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push development image
uses: docker/build-push-action@v5
with:
context: .
target: development
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+23 -2
View File
@@ -21,6 +21,18 @@ jobs:
python-version: ["3.11"]
steps:
- uses: actions/checkout@v3
- name: Free disk space
if: runner.os == 'Linux'
run: |
df -h
sudo rm -rf /usr/local/lib/android
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache
sudo apt-get clean
sudo apt-get autoremove -y
docker system prune -af || true
df -h
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
@@ -78,8 +90,8 @@ jobs:
if: runner.os != 'Windows'
uses: erlef/setup-beam@v1
with:
elixir-version: "1.18.4"
otp-version: "26.1"
elixir-version: "1.19.3"
otp-version: "28"
# Erlang currently not tested in CI, random hangings on macos, always hangs on ubuntu
# In local tests, erlang seems to work though
# - name: Install Erlang Language Server
@@ -103,6 +115,10 @@ jobs:
with:
distribution: 'temurin'
java-version: '17'
- name: Setup .NET SDK (for F# and C# languages)
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Install Terraform
uses: hashicorp/setup-terraform@v3
with:
@@ -359,6 +375,11 @@ jobs:
key: language-servers-${{ runner.os }}-v1
restore-keys: |
language-servers-${{ runner.os }}-
- name: Report free disk space
if: runner.os == 'Linux'
run: |
echo "Free disk space before tests:"
df -h
- name: Test with pytest
shell: bash
run: uv run poe test
+4 -1
View File
@@ -251,4 +251,7 @@ test/resources/repos/scala/.bloop/
stack.yaml.lock
dist-newstyle/
cabal.project.local*
.ghc.environment.*
.ghc.environment.*
zz-misc/
vue-implementation/
+2
View File
@@ -38,6 +38,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* **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 F#** via FsAutoComplete/Ionide LSP server.
* **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).
@@ -52,6 +53,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* **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.
* **Add support for PowerShell** via PowerShell Editor Services (PSES). Requires `pwsh` (PowerShell Core) to be installed and available in PATH. Supports symbol navigation, go-to-definition, and within-file references for .ps1 files.
# 0.1.4
+3 -2
View File
@@ -9,11 +9,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- `uv run poe type-check` - Run mypy type checking - ONLY allowed type checking command
- `uv run poe test` - Run tests with default markers (excludes java/rust by default)
- `uv run poe test -m "python or go"` - Run specific language tests
- `uv run poe test -m vue` - Run Vue tests
- `uv run poe lint` - Check code style without fixing
**Test Markers:**
Available pytest markers for selective testing:
- `python`, `go`, `java`, `rust`, `typescript`, `php`, `perl`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph`
- `python`, `go`, `java`, `rust`, `typescript`, `vue`, `php`, `perl`, `powershell`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph`
- `snapshot` - for symbolic editing operation tests
**Project Management:**
@@ -100,7 +101,7 @@ Configuration is loaded from (in order of precedence):
- **Symbol-based editing** - Uses LSP for precise code manipulation
- **Caching strategy** - Reduces language server overhead
- **Error recovery** - Automatic language server restart on crashes
- **Multi-language support** - 16+ languages with LSP integration
- **Multi-language support** - 19 languages with LSP integration (including Vue)
- **MCP protocol** - Exposes tools to AI agents via Model Context Protocol
- **Async operation** - Non-blocking language server interactions
+29 -37
View File
@@ -1,6 +1,6 @@
# Docker Setup for Serena (Experimental)
⚠️ **EXPERIMENTAL FEATURE**: The Docker setup for Serena is currently experimental and has several limitations. Please read this entire document before using Docker with Serena.
⚠️ **EXPERIMENTAL FEATURE**: The Docker setup for Serena is still experimental and has some limitations. Please read this entire document before using Docker with Serena.
## Overview
@@ -12,27 +12,40 @@ Docker support allows you to run Serena in an isolated container environment, wh
- **Consistent dependencies**: No need to manage language servers and dependencies on your host system
- **Cross-platform support**: Works consistently across Windows, macOS, and Linux
## Important Limitations and Caveats
## Important Usage Pointers
### 1. Configuration File Conflicts
### Configuration
⚠️ **Critical**: Docker uses a separate configuration file (`serena_config.docker.yml`) to avoid path conflicts. When running in Docker:
- Container paths will be stored in the configuration (e.g., `/workspaces/serena/...`)
- These paths are incompatible with non-Docker usage
- After using Docker, you cannot directly switch back to non-Docker usage without manual configuration adjustment
Serena's configuration and log files are stored in the container in `/workspaces/serena/config/`.
Any local configuration you may have for Serena will not apply; the container uses its own separate configuration.
### 2. Project Activation Limitations
You can mount a local configuration/data directory to persist settings across container restarts
(which will also contain session log files).
Simply mount your local directory to `/workspaces/serena/config` in the container.
Initially, be sure to add a `serena_config.yml` file to the mounted directory which applies the following
special settings for Docker usage:
```
# Disable the GUI log window since it's not supported in Docker
gui_log_window: False
# Disable opening the web dashboard on launch (not possible within the container)
web_dashboard_open_on_launch: False
```
Set other configuration options as needed.
### Project Activation Limitations
- **Only mounted directories work**: Projects must be mounted as volumes to be accessible
- Projects outside the mounted directories cannot be activated or accessed
- Default setup only mounts the current directory
- Since projects are not remembered across container restarts (unless you mount a local configuration as described above),
activate them using the full path (e.g. `/workspaces/projects/my-project`) when using dynamic project activation
### 3. GUI Window Disabled
### Language Support Limitations
- The GUI log window option is automatically disabled in Docker environments
- Use the web dashboard instead (see below)
The default Docker image does not include dependencies for languages that
require explicit system-level installations.
Only languages that install their requirements on the fly will work out of the box.
### 4. Dashboard Port Configuration
### Dashboard Port Configuration
The web dashboard runs on port 24282 (0x5EDA) by default. You can configure this using environment variables:
@@ -46,7 +59,7 @@ SERENA_DASHBOARD_PORT=8080 docker-compose up serena
⚠️ **Note**: If the local port is occupied, you'll need to specify a different port using the environment variable.
### 5. Line Ending Issues on Windows
### Line Ending Issues on Windows
⚠️ **Windows Users**: Be aware of potential line ending inconsistencies:
- Files edited within the Docker container may use Unix line endings (LF)
@@ -70,7 +83,7 @@ SERENA_DASHBOARD_PORT=8080 docker-compose up serena
Note: Edit the `compose.yaml` file to customize volume mounts for your projects.
### Using Docker directly
### Building the Docker Image Manually
```bash
# Build the image
@@ -98,7 +111,7 @@ services:
- /path/to/another/project:/workspace/another-project
# Add the context for the IDE assistant option:
command:
- "uv run --directory . serena-mcp-server --transport sse --port 9121 --host 0.0.0.0 --context ide-assistant"
- "uv run --directory . serena-mcp-server --transport sse --port 9121 --host 0.0.0.0 --context claude-code"
```
See the [Docker Merge Compose files documentation](https://docs.docker.com/compose/how-tos/multiple-compose-files/merge/) for more details on using merge files.
@@ -157,24 +170,3 @@ Ensure projects are properly mounted:
- Check volume mounts in `docker-compose.yaml`
- Use absolute paths for external projects
- Verify permissions on mounted directories
## Migration Path
To switch between Docker and non-Docker usage:
1. **Docker to Non-Docker**:
- Manually edit project paths in `serena_config.yml`
- Change container paths to host paths
- Or use separate config files for each environment
2. **Non-Docker to Docker**:
- Projects will be re-registered with container paths
- Original config remains unchanged
## Future Improvements
We're working on:
- Automatic config migration between environments
- Better project path handling
- Dynamic port allocation
- Windows line-ending handling.
+9 -18
View File
@@ -53,28 +53,20 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \
# Set the working directory
WORKDIR /workspaces/serena
# Development target
FROM base AS development
# Copy all files for development
COPY . /workspaces/serena/
# Create virtual environment and install dependencies with dev extras
RUN uv venv
RUN . .venv/bin/activate
RUN uv pip install --all-extras -r pyproject.toml -e .
ENV PATH="/workspaces/serena/.venv/bin:${PATH}"
# Install sed
RUN apt-get update && apt-get install -y sed
# Entrypoint to ensure environment is activated
ENTRYPOINT ["/bin/bash", "-c", "source .venv/bin/activate && $0 $@"]
# Create Serena configuration
ENV SERENA_HOME=/workspaces/serena/config
RUN mkdir -p $SERENA_HOME
RUN cp src/serena/resources/serena_config.template.yml $SERENA_HOME/serena_config.yml
RUN sed -i 's/^gui_log_window: .*/gui_log_window: False/' $SERENA_HOME/serena_config.yml
RUN sed -i 's/^web_dashboard_open_on_launch: .*/web_dashboard_open_on_launch: False/' $SERENA_HOME/serena_config.yml
# Production target
FROM base AS production
# Copy only necessary files for production
COPY pyproject.toml /workspaces/serena/
COPY README.md /workspaces/serena/
COPY src/ /workspaces/serena/src/
# Create virtual environment and install dependencies (production only)
# Create virtual environment and install dependencies
RUN uv venv
RUN . .venv/bin/activate
RUN uv pip install -r pyproject.toml -e .
@@ -82,4 +74,3 @@ ENV PATH="/workspaces/serena/.venv/bin:${PATH}"
# Entrypoint to ensure environment is activated
ENTRYPOINT ["/bin/bash", "-c", "source .venv/bin/activate && $0 $@"]
+1 -1
View File
@@ -68,7 +68,7 @@ than existing solutions that charge a premium.
Language servers provide support for a wide range of programming languages.
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.
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TypeScript, YAML and Zig.
> [!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.
+3 -20
View File
@@ -1,7 +1,7 @@
services:
serena:
image: serena:latest
# To work with projects, you must mount them as volumes:
# To work with projects, you must mount them into /workspace/ in the container:
# volumes:
# - ./my-project:/workspace/my-project
# - /path/to/another/project:/workspace/another-project
@@ -16,23 +16,6 @@ services:
- SERENA_DOCKER=1
command:
- "uv run --directory . serena-mcp-server --transport sse --port 9121 --host 0.0.0.0"
# Add the context for the IDE assistant
# - "uv run --directory . serena-mcp-server --transport sse --port 9121 --host 0.0.0.0 --context ide-assistant"
# Alternatively add further arguments, e.g. a context
# - "uv run --directory . serena-mcp-server --transport sse --port 9121 --host 0.0.0.0 --context ide"
serena-dev:
image: serena:dev
build:
context: ./
dockerfile: Dockerfile
target: development
tty: true
stdin_open: true
environment:
- SERENA_DOCKER=1
volumes:
- .:/workspaces/serena
ports:
- "${SERENA_PORT:-9121}:9121" # MCP server port
- "${SERENA_DASHBOARD_PORT:-24282}:24282" # Dashboard port
command:
- "uv run --directory . serena-mcp-server"
+7 -2
View File
@@ -20,12 +20,14 @@ With Serena, we provide direct, out-of-the-box support for:
* **Clojure**
* **Dart**
* **Elixir**
(requires installation of NextLS and Elixir; Windows not supported)
(requires Elixir installation; Expert language server is downloaded automatically)
* **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**
* **F#**
(requires .NET SDK 8.0+; uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment)
* **Fortran**
(requires installation of fortls: `pip install fortls`)
* **Go**
(requires installation of `gopls`)
@@ -56,6 +58,9 @@ With Serena, we provide direct, out-of-the-box support for:
(requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP)
* **Swift**
* **TypeScript**
* **Vue**
(3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection)
* **YAML**
* **Zig**
(requires installation of ZLS - Zig Language Server)
+14 -12
View File
@@ -47,28 +47,30 @@ As a consequence, you will need to specify paths when using CLI commands that wo
:::
(docker)=
### Using Docker (Experimental)
### Using Docker
:::{warning}
Docker support is currently experimental with several limitations.
:::
The Docker approach offers several advantages:
You can run the Serena MCP server directly via docker as follows,
* better security isolation for shell command execution
* no need to install language servers and dependencies locally
* consistent environment across different systems
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:
This command mounts your projects into the container under `/workspaces/projects`, so when working with projects,
you need to refer to them using the respective path (e.g. `/workspaces/projects/my-project`).
* Better security isolation for shell command execution
* No need to install language servers and dependencies locally
* Consistent environment across different systems
Alternatively, you may use Docker compose with the `compose.yml` file provided in the repository.
See our [advanced Docker usage](https://github.com/oraios/serena/blob/main/DOCKER.md) documentation for more detailed instructions, configuration options, and limitations.
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.
:::{note}
Docker usage is subject to limitations; see the [advanced Docker usage](https://github.com/oraios/serena/blob/main/DOCKER.md) documentation for details.
:::
### Using Nix
+19 -5
View File
@@ -13,18 +13,18 @@ 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)"
claude mcp add serena -- <serena> start-mcp-server --context claude-code --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)"
claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena start-mcp-server --context claude-code --project "$(pwd)"
```
Note:
* We use the `ide-assistant` context to disable unnecessary tools (avoiding duplication
* We use the `claude-code` 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
@@ -111,7 +111,21 @@ Add the `serena` MCP server configuration, using a [run command](020_running.md)
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.
:::{note}
Some language servers may require additional environment variables to be set (e.g. F# on macOS with Homebrew),
which you may need to explicitly add to the MCP server configuration.
Note that the spawned processes will not inherit environment variables that are only configured in your shell
profile (e.g. `.bashrc`, `.zshrc`, etc.); they would need to be set system-wide instead.
An easy fix is to add them explicitly to the MCP server entry: Simply add an `env` key to the `serena` object, e.g.
```
"env": {
"DOTNET_ROOT": "/opt/homebrew/Cellar/dotnet/9.0.8/libexec"
}
```
:::
Once you have created the new MCP server entry, save the config and then restart Claude Desktop.
:::{attention}
Be sure to fully quit the Claude Desktop application via File / Exit, as regularly closing the application will just minimize it.
@@ -151,7 +165,7 @@ by writing your own context, modes or prompts to adjust it to the client's respe
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`
We generally **recommend to use the `ide` context** for these integrations by adding the arguments `--context ide`
in order to reduce tool duplication.
### Local GUIs and Agent Frameworks
+46 -7
View File
@@ -44,9 +44,16 @@ A context is set at startup when launching Serena (e.g., via CLI options for an
Serena comes with pre-defined contexts:
* `desktop-app`: Tailored for use with desktop applications like Claude Desktop. This is the default.
The full set of Serena's tools is provided, as the application is assumed to have no prior coding-specific capabilities.
* `claude-code`: Optimized for use with Claude Code, it disables tools that would duplicate Claude Code's built-in capabilities.
* `codex`: Optimized for use with OpenAI Codex.
* `ide`: Generic context for IDE assistants/coding agents, e.g. VSCode, Cursor, or Cline, focusing on augmenting existing capabilities.
Basic file operations and shell execution are assumed to be handled by the assistant's own capabilities.
* `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.
* Choose the context that best matches the type of integration you are using.
Find the concrete definitions of these modes [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/contexts).
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.
@@ -74,18 +81,23 @@ Examples of built-in modes include:
* `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.
* `no-onboarding`: Skips the initial onboarding process if it's not needed for a particular session but retains the memory tools (assuming initial memories were created externally).
* `onboarding`: Focuses on the project onboarding process.
* `no-memories`: Disables all memory tools (and tools building on memories such as onboarding tools)
Find the concrete definitions of these modes [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/modes).
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`).
:::{attention}
**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,
You can manage modes using the `mode` command,
<serena> mode --help
<serena> mode list
@@ -93,4 +105,31 @@ You can manage contexts using the `mode` command,
<serena> mode edit <mode-name>
<serena> mode delete <mode-name>
where `<serena>` is [your way of running Serena](020_running).
where `<serena>` is [your way of running Serena](020_running).
## Advanced Configuration
For advanced users, Serena's configuration can be further customized.
### Serena Data Directory
The Serena user data directory (where configuration, language server files, logs, etc. are stored) defaults to `~/.serena`.
You can change this location by setting the `SERENA_HOME` environment variable to your desired path.
### Custom Prompts
All of Serena's prompts can be fully customized.
We define prompt as jinja templates in yaml files, and you can inspect our default prompts [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/prompt_templates).
To override a prompt, simply add a .yml file to the `prompt_templates` folder in your Serena data directory
which defines the prompt with the same name as the default prompt you want to override.
For example, to override the `system_prompt`, you could create a file `~/.serena/prompt_templates/system_prompt.yml` (assuming default Serena data folder location)
with content like:
```yaml
prompts:
system_prompt: |
Whatever you want ...
```
It is advisable to use the default prompt as a starting point and modify it to suit your needs.
+6 -2
View File
@@ -121,7 +121,7 @@ PYDEVD_DISABLE_FILE_VALIDATION = "1"
# Uses PYTEST_MARKERS env var for default markers
# For custom markers, one can either adjust the env var or just use -m option in the command line,
# as the second -m option will override the first one.
test = "pytest test -vv -m \"${PYTEST_MARKERS:-not rust and not erlang}\""
test = "pytest test -vv"
_black_check = "black --check src scripts test"
_ruff_check = "ruff check src scripts test"
_black_format = "black src scripts test"
@@ -275,6 +275,7 @@ markers = [
"kotlin: language server running for kotlin",
"rust: language server running for Rust",
"typescript: language server running for TypeScript",
"vue: language server running for Vue (uses TypeScript LSP)",
"php: language server running for PHP",
"perl: language server running for Perl",
"csharp: language server running for C#",
@@ -293,12 +294,15 @@ markers = [
"erlang: language server running for Erlang",
"scala: language server running for Scala",
"al: language server running for AL (Microsoft Dynamics 365 Business Central)",
"fsharp: language server running for F#",
"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",
"powershell: language server running for PowerShell",
"slow: tests that require additional Expert instances and have long startup times (~60-90s each)",
"toml: language server running for TOML",
]
@@ -307,4 +311,4 @@ markers = [
skip = '.git*,*.svg,*.lock,*.min.*'
check-hidden = true
# ignore-regex = ''
# ignore-words-list = ''
ignore-words-list = 'paket'
+11 -4
View File
@@ -271,8 +271,11 @@ class SerenaAgent:
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"
).run_in_thread(host=self.serena_config.web_dashboard_listen_address)
dashboard_host = self.serena_config.web_dashboard_listen_address
if dashboard_host == "0.0.0.0":
dashboard_host = "localhost"
dashboard_url = f"http://{dashboard_host}:{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
@@ -675,18 +678,22 @@ class SerenaAgent:
ToolRegistry().print_tool_overview(self._active_tools.values())
def __del__(self) -> None:
self.shutdown()
def shutdown(self, timeout: float = 2.0) -> None:
"""
Destructor to clean up the language server instance and GUI logger
Shuts down the agent, freeing resources and stopping background tasks.
"""
if not hasattr(self, "_is_initialized"):
return
log.info("SerenaAgent is shutting down ...")
if self._active_project is not None:
self._active_project.shutdown()
self._active_project.shutdown(timeout=timeout)
self._active_project = None
if self._gui_log_viewer:
log.info("Stopping the GUI log window ...")
self._gui_log_viewer.stop()
self._gui_log_viewer = None
def get_tool_by_name(self, tool_name: str) -> Tool:
tool_class = ToolRegistry().get_tool_class_by_name(tool_name)
+15 -18
View File
@@ -21,14 +21,10 @@ from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths
from serena.constants import (
DEFAULT_CONTEXT,
DEFAULT_MODES,
PROMPT_TEMPLATES_DIR_IN_USER_HOME,
PROMPT_TEMPLATES_DIR_INTERNAL,
SERENA_LOG_FORMAT,
SERENA_MANAGED_DIR_IN_HOME,
SERENAS_OWN_CONTEXT_YAMLS_DIR,
SERENAS_OWN_MODE_YAMLS_DIR,
USER_CONTEXT_YAMLS_DIR,
USER_MODE_YAMLS_DIR,
)
from serena.mcp import SerenaMCPFactory, SerenaMCPFactorySingleProcess
from serena.project import Project
@@ -270,7 +266,7 @@ class ModeCommands(AutoRegisteringGroup):
if not (name or from_internal):
raise click.UsageError("Provide at least one of --name or --from-internal.")
mode_name = name or from_internal
dest = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml")
dest = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
src = (
os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, f"{from_internal}.yml")
if from_internal
@@ -289,7 +285,7 @@ class ModeCommands(AutoRegisteringGroup):
@click.command("edit", help="Edit a custom mode YAML file.")
@click.argument("mode_name")
def edit(mode_name: str) -> None:
path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml")
path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
if not os.path.exists(path):
if mode_name in SerenaAgentMode.list_registered_mode_names(include_user_modes=False):
click.echo(
@@ -305,7 +301,7 @@ class ModeCommands(AutoRegisteringGroup):
@click.command("delete", help="Delete a custom mode file.")
@click.argument("mode_name")
def delete(mode_name: str) -> None:
path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml")
path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml")
if not os.path.exists(path):
click.echo(f"Custom mode '{mode_name}' not found.")
return
@@ -347,7 +343,7 @@ class ContextCommands(AutoRegisteringGroup):
if not (name or from_internal):
raise click.UsageError("Provide at least one of --name or --from-internal.")
ctx_name = name or from_internal
dest = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{ctx_name}.yml")
dest = os.path.join(SerenaPaths().user_contexts_dir, f"{ctx_name}.yml")
src = (
os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, f"{from_internal}.yml")
if from_internal
@@ -366,7 +362,7 @@ class ContextCommands(AutoRegisteringGroup):
@click.command("edit", help="Edit a custom context YAML file.")
@click.argument("context_name")
def edit(context_name: str) -> None:
path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml")
path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml")
if not os.path.exists(path):
if context_name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False):
click.echo(
@@ -382,7 +378,7 @@ class ContextCommands(AutoRegisteringGroup):
@click.command("delete", help="Delete a custom context file.")
@click.argument("context_name")
def delete(context_name: str) -> None:
path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml")
path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml")
if not os.path.exists(path):
click.echo(f"Custom context '{context_name}' not found.")
return
@@ -401,10 +397,9 @@ class SerenaConfigCommands(AutoRegisteringGroup):
"edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found."
)
def edit() -> None:
config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, "serena_config.yml")
if not os.path.exists(config_path):
SerenaConfig.generate_config_file(config_path)
_open_in_editor(config_path)
serena_config = SerenaConfig.from_config_file()
assert serena_config.config_file_path is not None
_open_in_editor(serena_config.config_file_path)
class ProjectCommands(AutoRegisteringGroup):
@@ -811,8 +806,9 @@ class PromptCommands(AutoRegisteringGroup):
@staticmethod
def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str:
os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True)
return os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, prompt_yaml_name)
templates_dir = SerenaPaths().user_prompt_templates_dir
os.makedirs(templates_dir, exist_ok=True)
return os.path.join(templates_dir, prompt_yaml_name)
@staticmethod
@click.command("list", help="Lists yamls that are used for defining prompts.")
@@ -863,9 +859,10 @@ class PromptCommands(AutoRegisteringGroup):
@staticmethod
@click.command("list-overrides", help="List existing prompt override files")
def list_overrides() -> None:
os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True)
user_templates_dir = SerenaPaths().user_prompt_templates_dir
os.makedirs(user_templates_dir, exist_ok=True)
serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")]
override_files = glob.glob(os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, "*.yml"))
override_files = glob.glob(os.path.join(user_templates_dir, "*.yml"))
for file_path in override_files:
if os.path.basename(file_path) in serena_prompt_yaml_names:
click.echo(file_path)
+1 -9
View File
@@ -267,15 +267,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
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)
if len(symbol_candidates) == 0:
raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}")
if len(symbol_candidates) > 1:
raise ValueError(
f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. "
"Their locations are: \n " + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)
)
return symbol_candidates[0]
return self._symbol_retriever.find_unique(name_path, within_relative_path=relative_file_path)
def _apply_workspace_edit(self, workspace_edit: ls_types.WorkspaceEdit) -> list[str]:
"""
+17 -42
View File
@@ -4,7 +4,6 @@ Context and Mode configuration loader
import os
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Self
@@ -12,7 +11,7 @@ import yaml
from sensai.util import logging
from sensai.util.string import ToStringMixin
from serena.config.serena_config import ToolInclusionDefinition
from serena.config.serena_config import SerenaPaths, ToolInclusionDefinition
from serena.constants import (
DEFAULT_CONTEXT,
DEFAULT_MODES,
@@ -20,8 +19,6 @@ from serena.constants import (
SERENA_FILE_ENCODING,
SERENAS_OWN_CONTEXT_YAMLS_DIR,
SERENAS_OWN_MODE_YAMLS_DIR,
USER_CONTEXT_YAMLS_DIR,
USER_MODE_YAMLS_DIR,
)
if TYPE_CHECKING:
@@ -66,14 +63,14 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
def get_path(cls, name: str) -> str:
"""Get the path to the YAML file for a mode."""
fname = f"{name}.yml"
custom_mode_path = os.path.join(USER_MODE_YAMLS_DIR, fname)
custom_mode_path = os.path.join(SerenaPaths().user_modes_dir, fname)
if os.path.exists(custom_mode_path):
return custom_mode_path
own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname)
if not os.path.exists(own_yaml_path):
raise FileNotFoundError(
f"Mode {name} not found in {USER_MODE_YAMLS_DIR} or in {SERENAS_OWN_MODE_YAMLS_DIR}."
f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}."
f"Available modes:\n{cls.list_registered_mode_names()}"
)
return own_yaml_path
@@ -103,7 +100,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
@classmethod
def list_custom_mode_names(cls) -> list[str]:
"""Names of all custom modes defined by the user."""
return [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")]
return [f.stem for f in Path(SerenaPaths().user_modes_dir).glob("*.yml")]
@classmethod
def load_default_modes(cls) -> list[Self]:
@@ -167,14 +164,14 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin):
def get_path(cls, name: str) -> str:
"""Get the path to the YAML file for a context."""
fname = f"{name}.yml"
custom_context_path = os.path.join(USER_CONTEXT_YAMLS_DIR, fname)
custom_context_path = os.path.join(SerenaPaths().user_contexts_dir, fname)
if os.path.exists(custom_context_path):
return custom_context_path
own_yaml_path = os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, fname)
if not os.path.exists(own_yaml_path):
raise FileNotFoundError(
f"Context {name} not found in {USER_CONTEXT_YAMLS_DIR} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}."
f"Context {name} not found in {SerenaPaths().user_contexts_dir} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}."
f"Available contexts:\n{cls.list_registered_context_names()}"
)
return own_yaml_path
@@ -182,6 +179,16 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin):
@classmethod
def from_name(cls, name: str) -> Self:
"""Load a registered Serena context."""
legacy_name_mapping = {
"ide-assistant": "claude-code",
}
if name in legacy_name_mapping:
log.warning(
f"Context name '{name}' is deprecated and has been renamed to '{legacy_name_mapping[name]}'. "
f"Please update your configuration; refer to the configuration guide for more details: "
"https://oraios.github.io/serena/02-usage/050_configuration.html#contexts"
)
name = legacy_name_mapping[name]
context_path = cls.get_path(name)
return cls.from_yaml(context_path)
@@ -202,7 +209,7 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin):
@classmethod
def list_custom_context_names(cls) -> list[str]:
"""Names of all custom contexts defined by the user."""
return [f.stem for f in Path(USER_CONTEXT_YAMLS_DIR).glob("*.yml")]
return [f.stem for f in Path(SerenaPaths().user_contexts_dir).glob("*.yml")]
@classmethod
def load_default(cls) -> Self:
@@ -214,35 +221,3 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin):
print(f"{self.name}:\n {self.description}")
if self.excluded_tools:
print(" excluded tools:\n " + ", ".join(sorted(self.excluded_tools)))
class RegisteredContext(Enum):
"""A registered context."""
IDE_ASSISTANT = "ide-assistant"
"""For Serena running within an assistant that already has basic tools, like Claude Code, Cline, Cursor, etc."""
DESKTOP_APP = "desktop-app"
"""For Serena running within Claude Desktop or a similar app which does not have built-in tools for code editing."""
AGENT = "agent"
"""For Serena running as a standalone agent, e.g. through agno."""
def load(self) -> SerenaAgentContext:
"""Load the context."""
return SerenaAgentContext.from_name(self.value)
class RegisteredMode(Enum):
"""A registered mode."""
INTERACTIVE = "interactive"
"""Interactive mode, for multi-turn interactions."""
EDITING = "editing"
"""Editing tools are activated."""
PLANNING = "planning"
"""Editing tools are deactivated."""
ONE_SHOT = "one-shot"
"""Non-interactive mode, where the goal is to finish a task autonomously."""
def load(self) -> SerenaAgentMode:
"""Load the mode."""
return SerenaAgentMode.from_name(self.value)
+62 -54
View File
@@ -25,10 +25,9 @@ from serena.constants import (
REPO_ROOT,
SERENA_CONFIG_TEMPLATE_FILE,
SERENA_FILE_ENCODING,
SERENA_MANAGED_DIR_IN_HOME,
SERENA_MANAGED_DIR_NAME,
)
from serena.util.general import load_yaml, save_yaml
from serena.util.general import get_dataclass_default, load_yaml, save_yaml
from serena.util.inspection import determine_programming_language_composition
from solidlsp.ls_config import Language
@@ -53,9 +52,32 @@ class SerenaPaths:
"""
def __init__(self) -> None:
self.user_config_dir: str = SERENA_MANAGED_DIR_IN_HOME
home_dir = os.getenv("SERENA_HOME")
if home_dir is None or home_dir.strip() == "":
home_dir = str(Path.home() / SERENA_MANAGED_DIR_NAME)
else:
home_dir = home_dir.strip()
self.serena_user_home_dir: str = home_dir
"""
the path to the user's Serena configuration directory, which is typically ~/.serena
the path to the Serena home directory, where the user's configuration/data is stored.
This is ~/.serena by default, but it can be overridden via the SERENA_HOME environment variable.
"""
self.user_prompt_templates_dir: str = os.path.join(self.serena_user_home_dir, "prompt_templates")
"""
directory containing prompt templates defined by the user.
Prompts defined by the user take precedence over Serena's built-in prompt templates.
"""
self.user_contexts_dir: str = os.path.join(self.serena_user_home_dir, "contexts")
"""
directory containing contexts defined by the user.
If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR,
the user context will override the default context definition.
"""
self.user_modes_dir: str = os.path.join(self.serena_user_home_dir, "modes")
"""
directory containing modes defined by the user.
If a name of a mode matches a name of a mode in SERENAS_OWN_MODES_YAML_DIR,
the user mode will override the default mode definition.
"""
def get_next_log_file_path(self, prefix: str) -> str:
@@ -63,7 +85,7 @@ class SerenaPaths:
:param prefix: the filename prefix indicating the type of the log file
:return: the full path to the log file to use
"""
log_dir = os.path.join(self.user_config_dir, "logs", datetime.now().strftime("%Y-%m-%d"))
log_dir = os.path.join(self.serena_user_home_dir, "logs", datetime.now().strftime("%Y-%m-%d"))
os.makedirs(log_dir, exist_ok=True)
return os.path.join(log_dir, prefix + "_" + datetime_tag() + ".txt")
@@ -84,19 +106,6 @@ def get_serena_managed_in_project_dir(project_root: str | Path) -> str:
return os.path.join(project_root, SERENA_MANAGED_DIR_NAME)
def is_running_in_docker() -> bool:
"""Check if we're running inside a Docker container."""
# Check for Docker-specific files
if os.path.exists("/.dockerenv"):
return True
# Check cgroup for docker references
try:
with open("/proc/self/cgroup") as f:
return "docker" in f.read()
except FileNotFoundError:
return False
@dataclass(kw_only=True)
class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
project_name: str
@@ -152,15 +161,18 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
f"Read the documentation for more information."
)
# sort languages by number of files found
languages_and_percentages = sorted(language_composition.items(), key=lambda item: item[1], reverse=True)
languages_and_percentages = sorted(
language_composition.items(), key=lambda item: (item[1], item[0].get_priority()), reverse=True
)
# find the language with the highest percentage and enable it
top_language_pair = languages_and_percentages[0]
other_language_pairs = languages_and_percentages[1:]
languages_to_use = [top_language_pair[0]]
languages_to_use: list[str] = [top_language_pair[0].value]
# if in interactive mode, ask the user which other languages to enable
if len(other_language_pairs) > 0 and interactive:
print(
"Detected and enabled main language '%s' (%.2f%% of source files)." % (top_language_pair[0], top_language_pair[1])
"Detected and enabled main language '%s' (%.2f%% of source files)."
% (top_language_pair[0].value, top_language_pair[1])
)
print(f"Additionally detected {len(other_language_pairs)} other language(s).\n")
print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.")
@@ -168,9 +180,9 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
print(" system-level installations/configuration (see Serena documentation).")
print("\nWhich additional languages do you want to enable?")
for lang, perc in other_language_pairs:
enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang, perc), default=False)
enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang.value, perc), default=False)
if enable:
languages_to_use.append(lang)
languages_to_use.append(lang.value)
print()
else:
languages_to_use = [lang.value for lang in languages]
@@ -342,6 +354,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
trace_lsp_communication: bool = False
web_dashboard: bool = True
web_dashboard_open_on_launch: bool = True
web_dashboard_listen_address: str = "127.0.0.1"
tool_timeout: float = DEFAULT_TOOL_TIMEOUT
loaded_commented_yaml: CommentedMap | None = None
config_file_path: str | None = None
@@ -371,13 +384,12 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
"""Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info."""
CONFIG_FILE = "serena_config.yml"
CONFIG_FILE_DOCKER = "serena_config.docker.yml" # Docker-specific config file; auto-generated if missing, mounted via docker-compose for user customization
def _tostring_includes(self) -> list[str]:
return ["config_file_path"]
@classmethod
def generate_config_file(cls, config_file_path: str) -> None:
def _generate_config_file(cls, config_file_path: str) -> None:
"""
Generates a Serena configuration file at the specified path from the template file.
@@ -392,20 +404,17 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
"""
:return: the location where the Serena configuration file is stored/should be stored
"""
if is_running_in_docker():
return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER)
else:
config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, cls.CONFIG_FILE)
config_path = os.path.join(SerenaPaths().serena_user_home_dir, cls.CONFIG_FILE)
# if the config file does not exist, check if we can migrate it from the old location
if not os.path.exists(config_path):
old_config_path = os.path.join(REPO_ROOT, cls.CONFIG_FILE)
if os.path.exists(old_config_path):
log.info(f"Moving Serena configuration file from {old_config_path} to {config_path}")
os.makedirs(os.path.dirname(config_path), exist_ok=True)
shutil.move(old_config_path, config_path)
# if the config file does not exist, check if we can migrate it from the old location
if not os.path.exists(config_path):
old_config_path = os.path.join(REPO_ROOT, cls.CONFIG_FILE)
if os.path.exists(old_config_path):
log.info(f"Moving Serena configuration file from {old_config_path} to {config_path}")
os.makedirs(os.path.dirname(config_path), exist_ok=True)
shutil.move(old_config_path, config_path)
return config_path
return config_path
@classmethod
def from_config_file(cls, generate_if_missing: bool = True) -> "SerenaConfig":
@@ -419,7 +428,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
if not generate_if_missing:
raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}")
log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...")
cls.generate_config_file(config_file_path)
cls._generate_config_file(config_file_path)
# load the configuration
log.info(f"Loading Serena configuration from {config_file_path}")
@@ -456,23 +465,22 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
instance.projects.append(project)
# set other configuration parameters
if is_running_in_docker():
instance.gui_log_window_enabled = False # not supported in Docker
else:
instance.gui_log_window_enabled = loaded_commented_yaml.get("gui_log_window", False)
def get_value_or_default(field_name: str) -> Any:
return loaded_commented_yaml.get(field_name, get_dataclass_default(SerenaConfig, field_name))
instance.gui_log_window_enabled = get_value_or_default("gui_log_window_enabled")
instance.web_dashboard_listen_address = get_value_or_default("web_dashboard_listen_address")
instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO))
instance.web_dashboard = loaded_commented_yaml.get("web_dashboard", True)
instance.web_dashboard_open_on_launch = loaded_commented_yaml.get("web_dashboard_open_on_launch", True)
instance.tool_timeout = loaded_commented_yaml.get("tool_timeout", DEFAULT_TOOL_TIMEOUT)
instance.trace_lsp_communication = loaded_commented_yaml.get("trace_lsp_communication", False)
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.token_count_estimator = loaded_commented_yaml.get(
"token_count_estimator", RegisteredTokenCountEstimator.TIKTOKEN_GPT4O.name
)
instance.default_max_tool_answer_chars = loaded_commented_yaml.get("default_max_tool_answer_chars", 150_000)
instance.ls_specific_settings = loaded_commented_yaml.get("ls_specific_settings", {})
instance.web_dashboard = get_value_or_default("web_dashboard")
instance.web_dashboard_open_on_launch = get_value_or_default("web_dashboard_open_on_launch")
instance.tool_timeout = get_value_or_default("tool_timeout")
instance.trace_lsp_communication = get_value_or_default("trace_lsp_communication")
instance.excluded_tools = get_value_or_default("excluded_tools")
instance.included_optional_tools = get_value_or_default("included_optional_tools")
instance.jetbrains = get_value_or_default("jetbrains")
instance.token_count_estimator = get_value_or_default("token_count_estimator")
instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars")
instance.ls_specific_settings = get_value_or_default("ls_specific_settings")
# re-save the configuration file if any migrations were performed
if num_project_migrations > 0:
-8
View File
@@ -4,22 +4,14 @@ _repo_root_path = Path(__file__).parent.parent.parent.resolve()
_serena_pkg_path = Path(__file__).parent.resolve()
SERENA_MANAGED_DIR_NAME = ".serena"
_serena_in_home_managed_dir = Path.home() / ".serena"
SERENA_MANAGED_DIR_IN_HOME = str(_serena_in_home_managed_dir)
# TODO: Path-related constants should be moved to SerenaPaths; don't add further constants here.
REPO_ROOT = str(_repo_root_path)
PROMPT_TEMPLATES_DIR_INTERNAL = str(_serena_pkg_path / "resources" / "config" / "prompt_templates")
PROMPT_TEMPLATES_DIR_IN_USER_HOME = str(_serena_in_home_managed_dir / "prompt_templates")
SERENAS_OWN_CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts")
"""The contexts that are shipped with the Serena package, i.e. the default contexts."""
USER_CONTEXT_YAMLS_DIR = str(_serena_in_home_managed_dir / "contexts")
"""Contexts defined by the user. If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, the user context will override the default one."""
SERENAS_OWN_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes")
"""The modes that are shipped with the Serena package, i.e. the default modes."""
USER_MODE_YAMLS_DIR = str(_serena_in_home_managed_dir / "modes")
"""Modes defined by the user. If a name of a mode matches a name of a mode in SERENAS_OWN_MODE_YAMLS_DIR, the user mode will override the default one."""
INTERNAL_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "internal_modes")
"""Internal modes, never overridden by user modes."""
SERENA_DASHBOARD_DIR = str(_serena_pkg_path / "resources" / "dashboard")
+4 -3
View File
@@ -560,7 +560,7 @@ class SerenaDashboardAPI:
raise RuntimeError(f"No free ports found starting from {start_port}")
def run(self, host: str = "0.0.0.0", port: int = 0x5EDA) -> int:
def run(self, host: str, port: int) -> int:
"""
Runs the dashboard on the given host and port and returns the port number.
"""
@@ -572,8 +572,9 @@ class SerenaDashboardAPI:
self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True)
return port
def run_in_thread(self) -> tuple[threading.Thread, int]:
def run_in_thread(self, host: str) -> tuple[threading.Thread, int]:
port = self._find_first_free_port(0x5EDA)
thread = threading.Thread(target=lambda: self.run(port=port), daemon=True)
log.info("Starting dashboard (listen_address=%s, port=%d)", host, port)
thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True)
thread.start()
return thread, port
+8 -6
View File
@@ -4,7 +4,8 @@ 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 serena.config.serena_config import SerenaPaths
from serena.constants import SERENA_MANAGED_DIR_NAME
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
@@ -43,7 +44,7 @@ class LanguageServerFactory:
self.project_root,
timeout=self.ls_timeout,
solidlsp_settings=SolidLSPSettings(
solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME,
solidlsp_dir=SerenaPaths().serena_user_home_dir,
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
ls_specific_settings=self.ls_specific_settings or {},
),
@@ -182,25 +183,26 @@ class LanguageServerManager:
self._stop_language_server(ls, save_cache=save_cache)
@staticmethod
def _stop_language_server(ls: SolidLanguageServer, save_cache: bool = False) -> None:
def _stop_language_server(ls: SolidLanguageServer, save_cache: bool = False, timeout: float = 2.0) -> None:
if ls.is_running():
if save_cache:
ls.save_cache()
log.info(f"Stopping language server for language {ls.language} ...")
ls.stop()
ls.stop(shutdown_timeout=timeout)
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:
def stop_all(self, save_cache: bool = False, timeout: float = 2.0) -> None:
"""
Stops all managed language servers.
:param save_cache: whether to save the cache before stopping
:param timeout: timeout for shutdown of each language server
"""
for ls in self.iter_language_servers():
self._stop_language_server(ls, save_cache=save_cache)
self._stop_language_server(ls, save_cache=save_cache, timeout=timeout)
def save_all_caches(self) -> None:
"""
+4 -1
View File
@@ -14,6 +14,7 @@ import docstring_parser
from mcp.server.fastmcp import server
from mcp.server.fastmcp.server import FastMCP, Settings
from mcp.server.fastmcp.tools.base import Tool as MCPTool
from mcp.types import ToolAnnotations
from pydantic_settings import SettingsConfigDict
from sensai.util import logging
@@ -213,6 +214,8 @@ class SerenaMCPFactory:
def execute_fn(**kwargs) -> str: # type: ignore
return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs)
annotations = ToolAnnotations(readOnlyHint=not tool.can_edit())
return MCPTool(
fn=execute_fn,
name=func_name,
@@ -221,7 +224,7 @@ class SerenaMCPFactory:
fn_metadata=func_arg_metadata,
is_async=is_async,
context_kwarg=None,
annotations=None,
annotations=annotations,
title=None,
)
+2 -2
View File
@@ -438,7 +438,7 @@ class Project(ToStringMixin):
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:
def shutdown(self, timeout: float = 2.0) -> None:
if self.language_server_manager is not None:
self.language_server_manager.stop_all(save_cache=True)
self.language_server_manager.stop_all(save_cache=True, timeout=timeout)
self.language_server_manager = None
+5 -3
View File
@@ -1,6 +1,7 @@
import os
from serena.constants import PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL
from serena.config.serena_config import SerenaPaths
from serena.constants import PROMPT_TEMPLATES_DIR_INTERNAL
from serena.generated.generated_prompt_factory import PromptFactory
@@ -10,5 +11,6 @@ class SerenaPromptFactory(PromptFactory):
"""
def __init__(self) -> None:
os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True)
super().__init__(prompts_dir=[PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL])
user_templates_dir = SerenaPaths().user_prompt_templates_dir
os.makedirs(user_templates_dir, exist_ok=True)
super().__init__(prompts_dir=[user_templates_dir, PROMPT_TEMPLATES_DIR_INTERNAL])
@@ -1,7 +1,6 @@
description: All tools except InitialInstructionsTool for agent context
description: Agent context where the system prompt (initial instructions) are provided at startup
prompt: |
You are running in agent context where the system prompt is provided externally. You should use symbolic
tools when possible for code understanding and modification.
You are running in an agent context.
excluded_tools:
- initial_instructions
@@ -1,4 +1,4 @@
description: A configuration specific for chatgpt, which has a limit of 30 tools and requires short descriptions.
description: A configuration specific for ChatGPT, which has a limit of 30 tools and requires short descriptions.
prompt: |
You are running in desktop app context where the tools give you access to the code base as well as some
access to the file system, if configured. You interact with the user through a chat interface that is separated
@@ -0,0 +1,26 @@
description: Claude Code (CLI agent where file operations, basic edits, etc. are already covered; single project mode)
prompt: |
You are running in a CLI coding agent context where file operations, basic (line-based) edits and reads
as well as shell commands are handled by your own, internal tools.
If Serena's tools can be used to achieve your task, you should prioritize them.
In particular, it is important that you avoid reading entire source code files unless it is strictly necessary!
Instead, for exploring and reading code in a token-efficient manner, use Serena's overview and symbolic search tools.
For non-code files or for reads where you don't know the symbol's name path, you can use the pattern search tool.
excluded_tools:
- create_text_file
- read_file
- execute_shell_command
- prepare_for_new_conversation
- 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 -15
View File
@@ -1,20 +1,13 @@
description: Non-symbolic editing tools and general shell tool are excluded
description: Codex Non-symbolic editing tools and general shell tool are excluded
prompt: |
You are running in IDE assistant context where file operations, basic (line-based) edits and reads,
and shell commands are handled by your own, internal tools.
The initial instructions and the current config inform you on which tools are available to you,
and how to use them.
Don't attempt to use any excluded tools, instead rely on your own internal tools
for achieving the basic file or shell operations.
You are running in the Codex IDE assistant mode, where file operations, basic (line-based) edits and reads
as well as shell commands are handled by your own, internal tools.
Don't attempt to use any excluded tools; instead, rely on your own internal tools for basic file or shell operations.
If serena's tools can be used for achieving your task,
you should prioritize them. In particular, it is important that you avoid reading entire source code files,
unless it is strictly necessary! Instead, for exploring and reading code in a token-efficient manner,
you should use serena's overview and symbolic search tools. The call of the read_file tool on an entire source code
file should only happen in exceptional cases, usually you should first explore the file (by itself or as part of exploring
the directory containing it) using the symbol_overview tool, and then make targeted reads using find_symbol and other symbolic tools.
For non-code files or for reads where you don't know the symbol's name path you can use the patterns searching tool,
using the read_file as a last resort.
If Serena's tools can be used to achieve your task, you should prioritize them.
In particular, it is important that you avoid reading entire source code files unless it is strictly necessary!
Instead, for exploring and reading code in a token-efficient manner, use Serena's overview and symbolic search tools.
For non-code files or for reads where you don't know the symbol's name path, you can use the pattern search tool.
excluded_tools:
- create_text_file
@@ -1,15 +1,13 @@
description: All tools included for desktop app context
description: Desktop application context (chat application detached from code) where Serena's full toolset is provided
prompt: |
You are running in desktop app context where the tools give you access to the code base as well as some
access to the file system, if configured. You interact with the user through a chat interface that is separated
from the code base. As a consequence, if you are in interactive mode, your communication with the user should
You are running in a desktop application context.
Serena's tools give you access to the code base as well as some access to the file system (if enabled).
You interact with the user through a chat interface that is separated from the code base.
As a consequence, if you are in interactive mode, your communication with the user should
involve high-level thinking and planning as well as some summarization of any code edits that you make.
For viewing the code edits the user will view them in a separate code editor window, and the back-and-forth
between the chat and the code editor should be minimized as well as facilitated by you.
If complex changes have been made, advise the user on how to review them in the code editor.
If complex relationships that the user asked for should be visualized or explained, consider creating
a diagram in addition to your text-based communication. Note that in the chat interface you have various rendering
options for text, html, and mermaid diagrams, as has been explained to you in your initial instructions.
To view the code edits you make, the user will have switch to a separate application.
To illustrate complex relationships, consider creating diagrams in addition to your text-based communication
(depending on the options for text, html, mermaid diagrams, etc. that you are provided with in your initial instructions).
excluded_tools: []
included_optional_tools:
- switch_modes
@@ -1,34 +0,0 @@
description: Non-symbolic editing tools and general shell tool are excluded
prompt: |
You are running in IDE assistant context where file operations, basic (line-based) edits and reads,
and shell commands are handled by your own, internal tools.
The initial instructions and the current config inform you on which tools are available to you,
and how to use them.
Don't attempt to use any excluded tools, instead rely on your own internal tools
for achieving the basic file or shell operations.
If serena's tools can be used for achieving your task,
you should prioritize them. In particular, it is important that you avoid reading entire source code files,
unless it is strictly necessary! Instead, for exploring and reading code in a token-efficient manner,
you should use serena's overview and symbolic search tools.
Before reading a full file, it is usually best to first explore the file using the symbol_overview tool,
and then make targeted reads using find_symbol and other symbolic tools.
For non-code files or for reads where you don't know the symbol's name path, you can use the pattern searching tool,
and read full files only as a last resort.
excluded_tools:
- create_text_file
- read_file
- execute_shell_command
- prepare_for_new_conversation
- 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
@@ -0,0 +1,25 @@
description: Generic IDE coding agent context (basic file operations and shell operations assumed to be covered; single project mode)
prompt: |
You are running in an IDE assistant context where file operations, basic (line-based) edits and reads,
and shell commands are handled by your own, internal tools.
If Serena's tools can be used to achieve your task, you should prioritize them.
In particular, it is important that you avoid reading entire source code files unless it is strictly necessary!
Instead, for exploring and reading code in a token-efficient manner, use Serena's overview and symbolic search tools.
For non-code files or for reads where you don't know the symbol's name path, you can use the pattern search tool.
excluded_tools:
- create_text_file
- read_file
- execute_shell_command
- prepare_for_new_conversation
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
+18 -26
View File
@@ -1,39 +1,31 @@
description: All tools, with detailed instructions for code editing
prompt: |
You are operating in editing mode. You can edit files with the provided tools
to implement the requested changes to the code base while adhering to the project's code style and patterns.
You are operating in editing mode. You can edit files with the provided tools.
You adhere to the project's code style and patterns.
Use symbolic editing tools whenever possible for precise code modifications.
If no editing task has yet been provided, wait for the user to provide one.
If no explicit editing task has yet been provided, wait for the user to provide one. Do not be overly eager.
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.
properly integrating them into the codebase.
You have two main approaches for editing code - editing at the symbol level and more file-based editing.
You have two main approaches for editing code: (a) editing at the symbol level and (b) 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.
It is not appropriate if you need to adjust just a few lines of code within a larger symbol.
Let us first discuss the symbol-based approach.
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.
Before calling symbolic reading tools, you should have a basic understanding of the repository structure that you can get from memories
or by using the `list_dir` and `find_file` tools (or similar).
For example, if you are working with python code and already know that you need to read the body of the constructor of the class Foo, you can directly
use `find_symbol` with the name path `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit,
you can use `find_symbol` with the name path `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding
to read the desired methods with `include_body=True`.
In particular, keep in mind the description of the `replace_symbol_body` tool. If you want to add some new code at the end of the file, you should
use the `insert_after_symbol` tool with the last top-level symbol in the file. If you want to add an import, often a good strategy is to use
`insert_before_symbol` with the first top-level symbol in the file.
You can understand relationships between symbols by using the `find_referencing_symbols` tool. If not explicitly requested otherwise by a user,
you make sure that when you edit a symbol, it is either done in a backward-compatible way, or you find and adjust the references as needed.
The `find_referencing_symbols` tool will give you code snippets around the references, as well as symbolic information.
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.
**Symbolic editing**
Use symbolic retrieval tools to identify the symbols you need to edit.
If you need to replace the definition of a symbol, use the `replace_symbol_body` tool.
If you want to add some new code at the end of the file, use the `insert_after_symbol` tool with the last top-level symbol in the file.
Similarly, you can use `insert_before_symbol` with the first top-level symbol in the file to insert code at the beginning of a file.
You can understand relationships between symbols by using the `find_referencing_symbols` tool. If not explicitly requested otherwise by the user,
you make sure that when you edit a symbol, the change is either backward-compatible or you find and update all references as needed.
The `find_referencing_symbols` tool will give you code snippets around the references as well as symbolic information.
You can assume that all symbol editing tools are reliable, so you never need to verify the results if the tools return without error.
{% if 'replace_content' in available_tools %}
The `replace_content` tool allows you to perform regex-based replacements within files.
**File-based editing**
The `replace_content` tool allows you to perform regex-based replacements within files (as well as simple string replacements).
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.
@@ -6,6 +6,6 @@ prompt: |
Break down complex tasks into smaller steps and explain your thinking at each stage. When you're uncertain about
a decision, present options to the user and ask for guidance rather than making assumptions.
Focus on providing informative results for intermediate steps so the user can follow along with your progress and
Focus on providing informative results for intermediate steps, such that the user can follow along with your progress and
provide feedback as needed.
excluded_tools: []
@@ -1,7 +1,8 @@
# See Serena's documentation for more details on concept of modes.
description: Description of the mode, not used in the code.
prompt: Prompt that will form part of the message sent to the model when activating this mode.
description: Description of the mode (meta-information only)
prompt: |
Provide a prompt that will form part of the instructions sent to the model when this mode is activated.
# tools that are to be excluded by this mode
excluded_tools: []
# several tools are excluded by default and have to be explicitly included by the user
included_optional_tools: []
@@ -0,0 +1,11 @@
description: Excludes Serena's memory tools (and onboarding tools, which rely on memory)
prompt: |
Serena's memory tools are not available and the onboarding workflow is not being applied.
excluded_tools:
- write_memory
- read_memory
- delete_memory
- edit_memory
- list_memories
- onboarding
- check_onboarding_performed
@@ -1,8 +1,6 @@
description: Onboarding was already performed, exclude all onboarding tools
description: The onboarding process is not used (memories may have been created externally)
prompt: |
You have already performed onboarding, meaning that memories have already been created.
Read a list of available memories using the `list_memories` tool before proceeding with the task.
You don't need to read the actual memories, just remember that they exist and that you can read them later if they are relevant for your task.
The onboarding process is not applied.
excluded_tools:
- onboarding
- check_onboarding_performed
@@ -1,8 +1,7 @@
description: Only read-only tools, focused on analysis and planning
prompt: |
You are operating in planning mode. Your task is to analyze code but not write any code.
The user may ask you to assist in creating a comprehensive plan, or to learn something about the codebase -
either a small aspect of it or about the whole project.
The user may ask you to assist in creating a comprehensive plan, or to learn something about the codebase.
excluded_tools:
- create_text_file
- replace_symbol_body
@@ -3,65 +3,52 @@
# is encouraged (via the tool description) to call at the beginning of the conversation.
prompts:
system_prompt: |
You are a professional coding agent concerned with one particular codebase. You have
access to semantic coding tools on which you rely heavily for all your work, as well as collection of memory
files containing general information about the codebase. You operate in a resource-efficient and intelligent manner, always
keeping in mind to not read or generate content that is not needed for the task at hand.
You are a professional coding agent.
You have access to semantic coding tools upon which you rely heavily for all your work.
You operate in a resource-efficient and intelligent manner, always keeping in mind to not read or generate
content that is not needed for the task at hand.
When reading code in order to answer a user question or task, you should try reading only the necessary code.
Some tasks may require you to understand the architecture of large parts of the codebase, while for others,
it may be enough to read a small set of symbols or a single file.
Generally, you should avoid reading entire files unless it is absolutely necessary, instead relying on
intelligent step-by-step acquisition of information. {% if 'ToolMarkerSymbolicRead' in available_markers %}However, if you already read a file, it does not make
sense to further analyse it with the symbolic tools (except for the `find_referencing_symbols` tool),
as you already have the information.{% endif %}
You avoid reading entire files unless it is absolutely necessary, instead relying on intelligent step-by-step
acquisition of information. {% if 'ToolMarkerSymbolicRead' in available_markers %}Once you have read a full file, it does not make
sense to analyse it with the symbolic read tools; you already have the information.{% endif %}
I WILL BE SERIOUSLY UPSET IF YOU READ ENTIRE FILES WITHOUT NEED!
{% if 'ToolMarkerSymbolicRead' in available_markers %}
CONSIDER INSTEAD USING THE OVERVIEW TOOL AND SYMBOLIC TOOLS TO READ ONLY THE NECESSARY CODE FIRST!
I WILL BE EVEN MORE UPSET IF AFTER HAVING READ AN ENTIRE FILE YOU KEEP READING THE SAME CONTENT WITH THE SYMBOLIC TOOLS!
THE PURPOSE OF THE SYMBOLIC TOOLS IS TO HAVE TO READ LESS CODE, NOT READ THE SAME CONTENT MULTIPLE TIMES!
{% endif %}
You can achieve the intelligent reading of code by using the symbolic tools for getting an overview of symbols and
the relations between them, and then only reading the bodies of symbols that are necessary to answer the question
or complete the task.
You can achieve intelligent reading of code by using the symbolic tools for getting an overview of symbols and
the relations between them, and then only reading the bodies of symbols that are necessary to complete the task at hand.
You can use the standard tools like list_dir, find_file and search_for_pattern if you need to.
When tools allow it, you pass the `relative_path` parameter to restrict the search to a specific file or directory.
For some tools, `relative_path` can only be a file path, so make sure to properly read the tool descriptions.
Where appropriate, you pass the `relative_path` parameter to restrict the search to a specific file or directory.
{% if 'search_for_pattern' in available_tools %}
If you are unsure about a symbol's name or location{% if 'find_symbol' in available_tools %} (to the extent that substring_matching for the symbol name is not enough){% endif %}, you can use the `search_for_pattern` tool, which allows fast
and flexible search for patterns in the codebase.{% if 'ToolMarkerSymbolicRead' in available_markers %}This way you can first find candidates for symbols or files,
and flexible search for patterns in the codebase.{% if 'ToolMarkerSymbolicRead' in available_markers %} In this way, you can first find candidates for symbols or files,
and then proceed with the symbolic tools.{% endif %}
{% endif %}
{% if 'ToolMarkerSymbolicRead' in available_markers %}
Symbols are identified by their `name_path` and `relative_path` (see the 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.
For example, if you are working with python code and already know that you need to read the body of the constructor of the class Foo, you can directly
You can get information about the symbols in a file by using the `get_symbols_overview` tool or use the `find_symbol` to search.
You only read the bodies of symbols when you need to (e.g. if you want to fully understand or edit it).
For example, if you are working with Python code and already know that you need to read the body of the constructor of the class Foo, you can directly
use `find_symbol` with name path pattern `Foo/__init__` and `include_body=True`. If you don't know yet which methods in `Foo` you need to read or edit,
you can use `find_symbol` with name path pattern `Foo`, `include_body=False` and `depth=1` to get all (top-level) methods of `Foo` before proceeding
to read the desired methods with `include_body=True`
to read the desired methods with `include_body=True`.
You can understand relationships between symbols by using the `find_referencing_symbols` tool.
{% endif %}
{% if 'read_memory' in available_tools %}
You generally have access to memories and it may be useful for you to read them, but also only if they help you
to answer the question or complete the task. You can infer which memories are relevant to the current task by reading
the memory names and descriptions.
You generally have access to memories and it may be useful for you to read them.
You infer whether memories are relevant based on their names.
{% endif %}
The context and modes of operation are described below. From them you can infer how to interact with your user
and which tasks and kinds of interactions are expected of you.
The context and modes of operation are described below. These determine how to interact with your user
and which kinds of interactions are expected of you.
Context description:
{{ context_system_prompt }}
Modes descriptions:
{% for prompt in mode_system_prompts %}
- {{ prompt }}
{{ prompt }}
{% endfor %}
You have hereby read the 'Serena Instructions Manual' and do not need to read it again.
@@ -20,6 +20,9 @@ web_dashboard: True
# shows Serena's current session logs - as an alternative to the GUI log window which
# is supported on all platforms.
web_dashboard_listen_address: 127.0.0.1
# The address where the web dashboard will listen on
web_dashboard_open_on_launch: True
# whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard
# is enabled). If set to False, you can still open the dashboard manually by navigating to
+114 -67
View File
@@ -2,7 +2,7 @@ import json
import logging
import os
from abc import ABC, abstractmethod
from collections.abc import Iterator, Sequence
from collections.abc import Callable, Iterator, Sequence
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, Union
@@ -115,16 +115,31 @@ class Symbol(ToStringMixin, ABC):
class NamePathMatcher(ToStringMixin):
def __init__(self, name_path_expr: str, substring_matching: bool) -> None:
"""
Matches name paths of symbols against search patterns.
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.
A matching pattern 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]".
"""
def __init__(self, name_path_pattern: str, substring_matching: bool) -> None:
"""
:param name_path_expr: the name path expression to match against
:param name_path_pattern: the name path expression to match against
:param substring_matching: whether to use substring matching for the last segment
"""
assert name_path_expr, "name_path must not be empty"
self._expr = name_path_expr
assert name_path_pattern, "name_path must not be empty"
self._expr = name_path_pattern
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)
self._is_absolute_pattern = name_path_pattern.startswith(NAME_PATH_SEP)
self._pattern_parts = name_path_pattern.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP)
# extract overload index "[idx]" if present at end of last part
self._overload_idx: int | None = None
@@ -194,6 +209,13 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
def symbol_kind(self) -> SymbolKind:
return self.symbol_root["kind"]
def is_low_level(self) -> bool:
"""
:return: whether the symbol is a low-level symbol (variable, constant, etc.), which typically represents data
rather than structure and therefore is not relevant in a high-level overview of the code.
"""
return self.symbol_kind >= SymbolKind.Variable.value
@property
def overload_idx(self) -> int | None:
return self.symbol_root.get("overload_idx")
@@ -323,34 +345,15 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
def find(
self,
name_path: str,
name_path_pattern: str,
substring_matching: bool = False,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
) -> list[Self]:
"""
Find all symbols within the symbol's subtree that match the given `name_path`.
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).
Find all symbols within the symbol's subtree that match the given name path pattern.
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`.
:param name_path: the name path expression to match against
:param name_path_pattern: the name path pattern to match against (see class :class:`NamePathMatcher` for details)
: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.
@@ -358,7 +361,7 @@ 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)
name_path_matcher = NamePathMatcher(name_path_pattern, substring_matching)
def should_include(s: "LanguageServerSymbol") -> bool:
if include_kinds is not None and s.symbol_kind not in include_kinds:
@@ -384,13 +387,14 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
include_body: bool = False,
include_children_body: bool = False,
include_relative_path: bool = True,
child_inclusion_predicate: Callable[[Self], bool] | None = None,
) -> dict[str, Any]:
"""
Converts the symbol to a dictionary.
:param kind: whether to include the kind of the symbol
:param location: whether to include the location of the symbol
:param depth: the depth of the symbol
:param depth: the depth up to which to include child symbols (0 = do not include children)
:param include_body: whether to include the body of the top-level symbol.
:param include_children_body: whether to also include the body of the children.
Note that the body of the children is part of the body of the parent symbol,
@@ -398,6 +402,8 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
and pass the children without passing the parent body to the LM.
:param include_relative_path: whether to include the relative path of the symbol in the location
entry. Relative paths of the symbol's children are always excluded.
:param child_inclusion_predicate: an optional predicate that decides whether a child symbol
should be included.
:return: a dictionary representation of the symbol
"""
result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()}
@@ -415,14 +421,20 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
log.warning("Requested body for symbol, but it is not present. The symbol might have been loaded with include_body=False.")
result["body"] = self.body
def add_children(s: Self) -> list[dict[str, Any]]:
if child_inclusion_predicate is None:
child_inclusion_predicate = lambda s: True
def included_children(s: Self) -> list[dict[str, Any]]:
children = []
for c in s.iter_children():
if not child_inclusion_predicate(c):
continue
children.append(
c.to_dict(
kind=kind,
location=location,
depth=depth - 1,
child_inclusion_predicate=child_inclusion_predicate,
include_body=include_children_body,
include_children_body=include_children_body,
# all children have the same relative path as the parent
@@ -432,7 +444,9 @@ class LanguageServerSymbol(Symbol, ToStringMixin):
return children
if depth > 0:
result["children"] = add_children(self)
children = included_children(self)
if len(children) > 0:
result["children"] = included_children(self)
return result
@@ -488,18 +502,17 @@ class LanguageServerSymbolRetriever:
def get_language_server(self, relative_path: str) -> SolidLanguageServer:
return self._ls_manager.get_language_server(relative_path)
def find_by_name(
def find(
self,
name_path: str,
name_path_pattern: str,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
substring_matching: bool = False,
within_relative_path: str | None = None,
) -> list[LanguageServerSymbol]:
"""
Find all symbols that match the given name. See docstring of `Symbol.find` for more details.
The only parameter not mentioned there is `within_relative_path`, which can be used to restrict the search
to symbols within a specific file or directory.
Finds all symbols that match the given name path pattern (see class :class:`NamePathMatcher` for details),
optionally limited to a specific file and filtered by kind.
"""
symbols: list[LanguageServerSymbol] = []
for lang_server in self._ls_manager.iter_language_servers():
@@ -507,11 +520,44 @@ class LanguageServerSymbolRetriever:
for root in symbol_roots:
symbols.extend(
LanguageServerSymbol(root).find(
name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
name_path_pattern, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
)
)
return symbols
def find_unique(
self,
name_path_pattern: str,
include_kinds: Sequence[SymbolKind] | None = None,
exclude_kinds: Sequence[SymbolKind] | None = None,
substring_matching: bool = False,
within_relative_path: str | None = None,
) -> LanguageServerSymbol:
symbol_candidates = self.find(
name_path_pattern,
include_kinds=include_kinds,
exclude_kinds=exclude_kinds,
substring_matching=substring_matching,
within_relative_path=within_relative_path,
)
if len(symbol_candidates) == 1:
return symbol_candidates[0]
elif len(symbol_candidates) == 0:
raise ValueError(f"No symbol matching '{name_path_pattern}' found")
else:
# There are multiple candidates.
# If only one of the candidates has the given pattern as its exact name path, return that one
exact_matches = [s for s in symbol_candidates if s.get_name_path() == name_path_pattern]
if len(exact_matches) == 1:
return exact_matches[0]
# otherwise, raise an error
include_rel_path = within_relative_path is not None
raise ValueError(
f"Found multiple {len(symbol_candidates)} symbols matching '{name_path_pattern}'. "
"They are: \n"
+ json.dumps([s.to_dict(kind=True, include_relative_path=include_rel_path) for s in symbol_candidates], indent=2)
)
def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None:
if location.relative_path is None:
return None
@@ -532,28 +578,17 @@ class LanguageServerSymbolRetriever:
exclude_kinds: Sequence[SymbolKind] | None = None,
) -> list[ReferenceInLanguageServerSymbol]:
"""
Find all symbols that reference the symbol with the given name.
If multiple symbols fit the name (e.g. for variables that are overwritten), will use the first one.
Find all symbols that reference the specified symbol, which is assumed to be unique.
:param name_path: the name path of the symbol to find
:param name_path: the name path of the symbol to find. (While this can be a matching pattern, it should
usually be the full path to ensure uniqueness.)
:param relative_file_path: the relative path of the file in which the referenced symbol is defined.
:param include_body: whether to include the body of all symbols in the result.
Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long.
:param include_kinds: which kinds of symbols to include in the result.
:param exclude_kinds: which kinds of symbols to exclude from the result.
"""
symbol_candidates = self.find_by_name(name_path, substring_matching=False, within_relative_path=relative_file_path)
if len(symbol_candidates) == 0:
log.warning(f"No symbol with name {name_path} found in file {relative_file_path}")
return []
if len(symbol_candidates) > 1:
log.error(
f"Found {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}."
f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the first one. "
f"Found symbols for {name_path=} in {relative_file_path=}: \n"
f"{json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)}"
)
symbol = symbol_candidates[0]
symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path)
return self.find_referencing_symbols_by_location(
symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds
)
@@ -604,23 +639,35 @@ class LanguageServerSymbolRetriever:
return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references]
@dataclass
class SymbolOverviewElement:
name_path: str
kind: int
@classmethod
def from_symbol(cls, symbol: LanguageServerSymbol) -> Self:
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]]:
def get_symbol_overview(self, relative_path: str, depth: int = 0) -> dict[str, list[dict]]:
"""
:param relative_path: the path of the file or directory for which to get the symbol overview
:param depth: the depth up to which to include child symbols (0 = only top-level symbols)
:return: a mapping from file paths to lists of symbol dictionaries.
For the case where a file is passed, the mapping will contain a single entry.
"""
lang_server = self.get_language_server(relative_path)
path_to_unified_symbols = lang_server.request_overview(relative_path)
def child_inclusion_predicate(s: LanguageServerSymbol) -> bool:
return not s.is_low_level()
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
# The language server methods would need to be adjusted for this.
result[file_path] = [self.SymbolOverviewElement.from_symbol(LanguageServerSymbol(s)) for s in unified_symbols]
symbols_in_file = []
for us in unified_symbols:
symbol = LanguageServerSymbol(us)
symbols_in_file.append(
symbol.to_dict(
depth=depth,
kind=True,
include_relative_path=False,
location=False,
child_inclusion_predicate=child_inclusion_predicate,
)
)
result[file_path] = symbols_in_file
return result
+3 -1
View File
@@ -14,7 +14,9 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject):
:param project: the name of a registered project to activate or a path to a project directory
"""
active_project = self.agent.activate_project_from_path_or_name(project)
return active_project.get_activation_message()
result = active_project.get_activation_message()
result += "\nIMPORTANT: If you have not yet read the 'Serena Instructions Manual', do it now before continuing!"
return result
class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional):
+10 -1
View File
@@ -133,7 +133,13 @@ class JetBrainsPluginClient(ToStringMixin):
return response["project_root"]
def find_symbol(
self, name_path: str, relative_path: str | None = None, include_body: bool = False, depth: int = 0, include_location: bool = False
self,
name_path: str,
relative_path: str | None = None,
include_body: bool = False,
depth: int = 0,
include_location: bool = False,
search_deps: bool = False,
) -> dict[str, Any]:
"""
Finds symbols by name.
@@ -142,6 +148,8 @@ class JetBrainsPluginClient(ToStringMixin):
:param relative_path: the relative path to which to restrict the search
:param include_body: whether to include symbol body content
:param depth: depth of children to include (0 = no children)
:param include_location: whether to include symbol location information
:param search_deps: whether to also search in dependencies
:return: Dictionary containing 'symbols' list with matching symbols
"""
@@ -151,6 +159,7 @@ class JetBrainsPluginClient(ToStringMixin):
"includeBody": include_body,
"depth": depth,
"includeLocation": include_location,
"searchDeps": search_deps,
}
return self._make_request("POST", "/findSymbol", request_data)
+3
View File
@@ -13,6 +13,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
depth: int = 0,
relative_path: str | None = None,
include_body: bool = False,
search_deps: bool = False,
max_answer_chars: int = -1,
) -> str:
"""
@@ -42,6 +43,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
If you have some knowledge about the codebase, you should use this parameter, as it will significantly
speed up the search as well as reduce the number of results.
:param include_body: If True, include the symbol's source code. Use judiciously.
:param search_deps: If True, also search in project dependencies (e.g., libraries).
: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: JSON string: a list of symbols (with locations) matching the name.
@@ -52,6 +54,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional):
relative_path=relative_path,
depth=depth,
include_body=include_body,
search_deps=search_deps,
)
result = self._to_json(response_dict)
return self._limit_length(result, max_answer_chars)
+6 -5
View File
@@ -2,7 +2,6 @@
Language server-related tools
"""
import dataclasses
import os
from collections.abc import Sequence
from copy import copy
@@ -50,13 +49,15 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
Gets an overview of the top-level symbols defined in a given file.
"""
def apply(self, relative_path: str, max_answer_chars: int = -1) -> str:
def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1) -> str:
"""
Use this tool to get a high-level understanding of the code symbols in a file.
This should be the first tool to call when you want to understand a new file, unless you already know
what you are looking for.
:param relative_path: the relative path to the file to get the overview of
:param depth: depth up to which descendants of top-level symbols shall be retrieved
(e.g. 1 retrieves immediate children). Default 0.
:param max_answer_chars: if the overview is longer than this number of characters,
no content will be returned. -1 means the default value from the config will be used.
Don't adjust unless there is really no other way to get the content required for the task.
@@ -71,8 +72,8 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead):
raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.")
if os.path.isdir(file_path):
raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ")
result = symbol_retriever.get_symbol_overview(relative_path)[relative_path]
result_json_str = self._to_json([dataclasses.asdict(i) for i in result])
result = symbol_retriever.get_symbol_overview(relative_path, depth=depth)[relative_path]
result_json_str = self._to_json(result)
return self._limit_length(result_json_str, max_answer_chars)
@@ -136,7 +137,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead):
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
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(
symbols = symbol_retriever.find(
name_path_pattern,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
+3 -3
View File
@@ -16,8 +16,7 @@ class CheckOnboardingPerformedTool(Tool):
def apply(self) -> str:
"""
Checks whether project onboarding was already performed.
You should always call this tool before beginning to actually work on the project/after activating a project,
but after calling the initial instructions tool.
You should always call this tool before beginning to actually work on the project/after activating a project.
"""
from .memory_tools import ListMemoriesTool
@@ -133,6 +132,7 @@ class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject):
def apply(self) -> str:
"""
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!
IMPORTANT: If you have not yet read the manual, call this tool immediately after you are given your task by the user,
as it will critically inform you!
"""
return self.agent.create_system_prompt()
+21 -1
View File
@@ -1,5 +1,6 @@
import os
from typing import Literal, overload
from dataclasses import MISSING, Field
from typing import Any, Literal, cast, overload
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
@@ -32,3 +33,22 @@ def save_yaml(path: str, data: dict | CommentedMap, preserve_comments: bool = Fa
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding=SERENA_FILE_ENCODING) as f:
yaml.dump(data, f)
def get_dataclass_default(cls: type, field_name: str) -> Any:
"""
Gets the default value of a dataclass field.
:param cls: The dataclass type.
:param field_name: The name of the field.
:return: The default value of the field (either from default or default_factory).
"""
field = cast(Field, cls.__dataclass_fields__[field_name]) # type: ignore[attr-defined]
if field.default is not MISSING:
return field.default
if field.default_factory is not MISSING: # default_factory is a function
return field.default_factory()
raise AttributeError(f"{field_name} has no default")
+7 -7
View File
@@ -19,13 +19,13 @@ def iter_subclasses(cls: type[T], recursive: bool = True) -> Generator[type[T],
yield from iter_subclasses(subclass, recursive)
def determine_programming_language_composition(repo_path: str) -> dict[str, float]:
def determine_programming_language_composition(repo_path: str) -> dict[Language, float]:
"""
Determine the programming language composition of a repository.
:param repo_path: Path to the repository to analyze
:return: Dictionary mapping language names to percentages of files matching each language
:return: Dictionary mapping languages to percentages of files matching each language
"""
all_files = find_all_non_ignored_files(repo_path)
@@ -33,7 +33,7 @@ def determine_programming_language_composition(repo_path: str) -> dict[str, floa
return {}
# Count files for each language
language_counts: dict[str, int] = {}
language_counts: dict[Language, int] = {}
total_files = len(all_files)
for language in Language.iter_all(include_experimental=False):
@@ -47,12 +47,12 @@ def determine_programming_language_composition(repo_path: str) -> dict[str, floa
count += 1
if count > 0:
language_counts[str(language)] = count
language_counts[language] = count
# Convert counts to percentages
language_percentages: dict[str, float] = {}
for language_name, count in language_counts.items():
language_percentages: dict[Language, float] = {}
for language, count in language_counts.items():
percentage = (count / total_files) * 100
language_percentages[language_name] = round(percentage, 2)
language_percentages[language] = round(percentage, 2)
return language_percentages
@@ -532,7 +532,7 @@ class CSharpLanguageServer(SolidLanguageServer):
# Map LSP message types to Python logging levels
level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} # Error # Warning # Info # Log
log.info(f"LSP: {message_text}", level_map.get(level, logging.DEBUG))
log.log(level_map.get(level, logging.DEBUG), f"LSP: {message_text}")
def handle_progress(params: dict) -> None:
"""Handle progress notifications from the language server."""
@@ -627,10 +627,14 @@ class CSharpLanguageServer(SolidLanguageServer):
def handle_project_needs_restore(params: dict) -> None:
return
def handle_workspace_indexing_complete(params: dict) -> None:
self.completions_available.set()
# Set up notification handlers
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", handle_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("workspace/projectInitializationComplete", handle_workspace_indexing_complete)
self.server.on_request("workspace/configuration", handle_workspace_configuration)
self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create)
self.server.on_request("client/registerCapability", handle_register_capability)
@@ -679,7 +683,6 @@ class CSharpLanguageServer(SolidLanguageServer):
self._open_solution_and_projects()
self.initialization_complete.set()
self.completions_available.set()
log.info(
"Microsoft.CodeAnalysis.LanguageServer initialized and ready\n"
@@ -687,6 +690,12 @@ class CSharpLanguageServer(SolidLanguageServer):
"This may take a while for large projects"
)
if self.completions_available.wait(30): # Wait up to 30 seconds for indexing
log.info("Indexing complete")
else:
log.warning("Timeout waiting for indexing to complete, proceeding anyway")
self.completions_available.set()
def _force_pull_diagnostics(self, init_response: dict | InitializeResult) -> None:
"""
Apply the diagnostic capabilities hack.
@@ -1,18 +1,6 @@
# Elixir Language Server Integration
This directory contains the integration for Elixir language support using [Next LS](https://github.com/elixir-tools/next-ls) from the elixir-tools project.
> **⚠️ Windows Not Supported**: Next LS does not provide Windows binaries, so Elixir language server integration is only available on Linux and macOS.
## Known Issues
### Next LS v0.23.3 Timeout Enumeration Bug
There is a known intermittent bug in Next LS v0.23.3 where `textDocument/definition` requests can fail with:
```
Protocol.UndefinedError: protocol Enumerable not implemented for :timeout of type Atom
```
This bug is tracked in [Next LS Issue #543](https://github.com/elixir-tools/next-ls/issues/543) and primarily occurs in CI environments. The affected test (`test_request_defining_symbol_none`) is marked as expected to fail until this upstream bug is resolved.
This directory contains the integration for Elixir language support using [Expert](https://github.com/elixir-lang/expert), the official Elixir language server.
## Prerequisites
@@ -22,9 +10,10 @@ Before using the Elixir language server integration, you need to have:
- Install from: https://elixir-lang.org/install.html
- Verify with: `elixir --version`
2. **Next LS** installed and available in your PATH
- Install from: https://github.com/elixir-tools/next-ls#installation
- Verify with: `nextls --version`
2. **Expert** (optional - will be downloaded automatically if not found)
- Expert binaries are automatically downloaded from GitHub releases
- Manual installation: https://github.com/elixir-lang/expert#installation
- If installed manually, ensure `expert` is in your PATH
## Features
@@ -42,13 +31,20 @@ The Elixir integration provides:
## Configuration
The integration uses the default Next LS configuration with:
The integration uses the default Expert configuration with:
- **MIX_ENV**: `dev`
- **MIX_TARGET**: `host`
- **Experimental completions**: Disabled by default
- **Credo extension**: Enabled by default
### Version Management (asdf)
Expert automatically respects project-specific Elixir versions when using asdf:
- If a `.tool-versions` file exists in the project root, Expert will use the specified Elixir version
- Expert is launched from the project directory, allowing it to pick up project configuration
- No additional configuration needed - just ensure asdf is installed and the project has a `.tool-versions` file
## Usage
The Elixir language server is automatically selected when working with Elixir projects. It will be used for:
@@ -63,14 +59,14 @@ The Elixir language server is automatically selected when working with Elixir pr
### Important: Project Compilation
Next LS requires your Elixir project to be **compiled** for optimal performance, especially for:
Expert requires your Elixir project to be **compiled** for optimal performance, especially for:
- Cross-file reference resolution
- Complete symbol information
- Accurate go-to-definition
**For production use**: Ensure your project is compiled with `mix compile` before using the language server.
**For testing**: The test suite automatically compiles the test repositories before running tests to ensure optimal Next LS performance.
**For testing**: The test suite automatically compiles the test repositories before running tests to ensure optimal Expert performance.
## Testing
@@ -83,8 +79,9 @@ pytest test/solidlsp/elixir/ -m elixir
## Implementation Details
- **Main class**: `ElixirTools` in `elixir_tools.py`
- **Initialization parameters**: Defined in `initialize_params.json`
- **Language identifier**: `"elixir"`
- **Command**: `nextls --stdio`
- **Command**: `expert --stdio`
- **Supported platforms**: Linux (x64, arm64), macOS (x64, arm64), Windows (x64, arm64)
- **Binary distribution**: Downloaded from [GitHub releases](https://github.com/elixir-lang/expert/releases)
The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior.
The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior.
@@ -11,7 +11,6 @@ from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils
from solidlsp.lsp_protocol_handler import lsp_types
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
@@ -23,12 +22,12 @@ log = logging.getLogger(__name__)
class ElixirTools(SolidLanguageServer):
"""
Provides Elixir specific instantiation of the LanguageServer class using Next LS from elixir-tools.
Provides Elixir specific instantiation of the LanguageServer class using Expert, the official Elixir language server.
"""
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 10.0 # Elixir projects need a lot of time to compile and index before cross-file references work
return 10.0 # Elixir projects need time to compile and index before cross-file references work
@override
def is_ignored_dirname(self, dirname: str) -> bool:
@@ -38,7 +37,8 @@ class ElixirTools(SolidLanguageServer):
# - node_modules: if the project has JavaScript components
# - .elixir_ls: ElixirLS artifacts (in case both are present)
# - cover: coverage reports
return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", "cover"]
# - .expert: Expert artifacts
return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", ".expert", "cover"]
@override
def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool:
@@ -49,42 +49,6 @@ class ElixirTools(SolidLanguageServer):
return super().is_ignored_path(relative_path, ignore_unsupported_files)
@staticmethod
def _is_next_ls_internal_file(abs_path: str) -> bool:
"""Check if an absolute path is a Next LS internal file that should be ignored."""
return any(
pattern in abs_path
for pattern in [
".burrito", # Next LS runtime directory
"next_ls_erts-", # Next LS Erlang runtime
"_next_ls_private_", # Next LS private files
"/priv/monkey/", # Next LS monkey patching directory
]
)
@override
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
"""Override to filter out Next LS internal files from references."""
from solidlsp.ls_utils import PathUtils
# Get the raw response from the parent implementation
raw_response = super()._send_references_request(relative_file_path, line, column)
if raw_response is None:
return None
# Filter out Next LS internal files
filtered_response = []
for item in raw_response:
if isinstance(item, dict) and "uri" in item:
abs_path = PathUtils.uri_to_path(item["uri"])
if self._is_next_ls_internal_file(abs_path):
log.debug(f"Filtering out Next LS internal file: {abs_path}")
continue
filtered_response.append(item)
return filtered_response
@classmethod
def _get_elixir_version(cls) -> str | None:
"""Get the installed Elixir version or None if not found."""
@@ -99,8 +63,8 @@ class ElixirTools(SolidLanguageServer):
@classmethod
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
"""
Setup runtime dependencies for Next LS.
Downloads the Next LS binary for the current platform and returns the path to the executable.
Setup runtime dependencies for Expert.
Downloads the Expert binary for the current platform and returns the path to the executable.
"""
# Check if Elixir is available first
elixir_version = cls._get_elixir_version()
@@ -111,113 +75,137 @@ class ElixirTools(SolidLanguageServer):
log.info(f"Found Elixir: {elixir_version}")
platform_id = PlatformUtils.get_platform_id()
# First, check if expert is already in PATH (user may have installed it manually)
import shutil
# Check for Windows and provide a helpful error message
if platform_id.value.startswith("win"):
raise RuntimeError(
"Windows is not supported by Next LS. The Next LS project does not provide Windows binaries. "
"Consider using Windows Subsystem for Linux (WSL) or a virtual machine with Linux/macOS."
)
expert_in_path = shutil.which("expert")
if expert_in_path:
log.info(f"Found Expert in PATH: {expert_in_path}")
return expert_in_path
platform_id = PlatformUtils.get_platform_id()
valid_platforms = [
PlatformId.LINUX_x64,
PlatformId.LINUX_arm64,
PlatformId.OSX_x64,
PlatformId.OSX_arm64,
PlatformId.WIN_x64,
PlatformId.WIN_arm64,
]
assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for Next LS at the moment"
assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for Expert at the moment"
next_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "next-ls")
expert_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "expert")
NEXTLS_VERSION = "v0.23.4"
EXPERT_VERSION = "nightly"
# Define runtime dependencies inline
runtime_deps = {
PlatformId.LINUX_x64: RuntimeDependency(
id="next_ls_linux_amd64",
id="expert_linux_amd64",
platform_id="linux-x64",
url=f"https://github.com/elixir-tools/next-ls/releases/download/{NEXTLS_VERSION}/next_ls_linux_amd64",
url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_linux_amd64",
archive_type="binary",
binary_name="next_ls_linux_amd64",
extract_path="next_ls",
binary_name="expert_linux_amd64",
extract_path="expert",
),
PlatformId.LINUX_arm64: RuntimeDependency(
id="next_ls_linux_arm64",
id="expert_linux_arm64",
platform_id="linux-arm64",
url=f"https://github.com/elixir-tools/next-ls/releases/download/{NEXTLS_VERSION}/next_ls_linux_arm64",
url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_linux_arm64",
archive_type="binary",
binary_name="next_ls_linux_arm64",
extract_path="next_ls",
binary_name="expert_linux_arm64",
extract_path="expert",
),
PlatformId.OSX_x64: RuntimeDependency(
id="next_ls_darwin_amd64",
id="expert_darwin_amd64",
platform_id="osx-x64",
url=f"https://github.com/elixir-tools/next-ls/releases/download/{NEXTLS_VERSION}/next_ls_darwin_amd64",
url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_darwin_amd64",
archive_type="binary",
binary_name="next_ls_darwin_amd64",
extract_path="next_ls",
binary_name="expert_darwin_amd64",
extract_path="expert",
),
PlatformId.OSX_arm64: RuntimeDependency(
id="next_ls_darwin_arm64",
id="expert_darwin_arm64",
platform_id="osx-arm64",
url=f"https://github.com/elixir-tools/next-ls/releases/download/{NEXTLS_VERSION}/next_ls_darwin_arm64",
url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_darwin_arm64",
archive_type="binary",
binary_name="next_ls_darwin_arm64",
extract_path="next_ls",
binary_name="expert_darwin_arm64",
extract_path="expert",
),
PlatformId.WIN_x64: RuntimeDependency(
id="expert_windows_amd64",
platform_id="win-x64",
url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_windows_amd64.exe",
archive_type="binary",
binary_name="expert_windows_amd64.exe",
extract_path="expert.exe",
),
PlatformId.WIN_arm64: RuntimeDependency(
id="expert_windows_arm64",
platform_id="win-arm64",
url=f"https://github.com/elixir-lang/expert/releases/download/{EXPERT_VERSION}/expert_windows_arm64.exe",
archive_type="binary",
binary_name="expert_windows_arm64.exe",
extract_path="expert.exe",
),
}
dependency = runtime_deps[platform_id]
executable_path = os.path.join(next_ls_dir, "nextls")
# On Windows, use .exe extension
executable_name = "expert.exe" if platform_id.value.startswith("win") else "expert"
executable_path = os.path.join(expert_dir, executable_name)
assert dependency.binary_name is not None
binary_path = os.path.join(next_ls_dir, dependency.binary_name)
binary_path = os.path.join(expert_dir, dependency.binary_name)
if not os.path.exists(executable_path):
log.info(f"Downloading Next LS binary from {dependency.url}")
log.info(f"Downloading Expert binary from {dependency.url}")
assert dependency.url is not None
FileUtils.download_file(dependency.url, binary_path)
# Make the binary executable on Unix-like systems
os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
if not platform_id.value.startswith("win"):
os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
# Create a symlink with the expected name
if binary_path != executable_path:
# Create a symlink with the expected name on Unix-like systems
if binary_path != executable_path and not platform_id.value.startswith("win"):
if os.path.exists(executable_path):
os.remove(executable_path)
os.symlink(os.path.basename(binary_path), executable_path)
assert os.path.exists(executable_path), f"Next LS executable not found at {executable_path}"
assert os.path.exists(executable_path), f"Expert executable not found at {executable_path}"
log.info(f"Next LS binary ready at: {executable_path}")
log.info(f"Expert binary ready at: {executable_path}")
return executable_path
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
nextls_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
expert_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=f'"{nextls_executable_path}" --stdio', cwd=repository_root_path),
ProcessLaunchInfo(cmd=f"{expert_executable_path} --stdio", cwd=repository_root_path),
"elixir",
solidlsp_settings,
)
self.server_ready = threading.Event()
self.request_id = 0
# Set generous timeout for Next LS which can be slow to initialize and respond
self.set_request_timeout(180.0) # 60 seconds for all environments
# Set generous timeout for Expert which can be slow to initialize and respond
self.set_request_timeout(180.0)
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Next LS Language Server.
Returns the initialize params for the Expert Language Server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
# Ensure the path is absolute
abs_path = os.path.abspath(repository_absolute_path)
root_uri = pathlib.Path(abs_path).as_uri()
initialize_params = {
"processId": os.getpid(),
"locale": "en",
"rootPath": repository_absolute_path,
"rootPath": abs_path,
"rootUri": root_uri,
"initializationOptions": {
"mix_env": "dev",
@@ -275,89 +263,80 @@ class ElixirTools(SolidLanguageServer):
return cast(InitializeParams, initialize_params)
def _start_server(self) -> None:
"""Start Next LS server process"""
"""Start Expert server process"""
def register_capability_handler(params: Any) -> None:
log.debug(f"LSP: client/registerCapability: {params}")
return
def window_log_message(msg: Any) -> None:
"""Handle window/logMessage notifications from Next LS"""
"""Handle window/logMessage notifications from Expert"""
message_type = msg.get("type", 4) # 1=Error, 2=Warning, 3=Info, 4=Log
message_text = msg.get("message", "")
log.info(f"LSP: window/logMessage: {message_text}")
# Check for the specific Next LS readiness signal
# Based on Next LS source: "Runtime for folder #{name} is ready..."
if "Runtime for folder" in message_text and "is ready..." in message_text:
log.info("Next LS runtime is ready based on official log message")
self.server_ready.set()
def do_nothing(params: Any) -> None:
return
# Log at appropriate level based on message type
if message_type == 1:
log.error(f"Expert: {message_text}")
elif message_type == 2:
log.warning(f"Expert: {message_text}")
else:
log.debug(f"Expert: {message_text}")
def check_server_ready(params: Any) -> None:
"""
Handle $/progress notifications from Next LS.
Keep as fallback for error detection, but primary readiness detection
is now done via window/logMessage handler.
Handle $/progress notifications from Expert.
Expert sends progress updates during compilation and indexing.
The server is considered ready when project build completes.
"""
value = params.get("value", {})
kind = value.get("kind", "")
title = value.get("title", "")
# Check for initialization completion progress (fallback signal)
if value.get("kind") == "end":
message = value.get("message", "")
if "has initialized!" in message:
log.info("Next LS initialization progress completed")
# Note: We don't set server_ready here - we wait for the log message
if kind == "begin":
# Track when building the project starts (not "Building engine")
if title.startswith("Building ") and not title.startswith("Building engine"):
self._building_project = True
elif kind == "end":
# Project build completion is the main readiness signal
if getattr(self, "_building_project", False):
log.debug("Expert project build completed - server is ready")
self._building_project = False
self.server_ready.set()
def work_done_progress(params: Any) -> None:
"""
Handle $/workDoneProgress notifications from Next LS.
Keep for completeness but primary readiness detection is via window/logMessage.
"""
value = params.get("value", {})
if value.get("kind") == "end":
log.info("Next LS work done progress completed")
# Note: We don't set server_ready here - we wait for the log message
def work_done_progress_create(params: Any) -> None:
"""Handle window/workDoneProgress/create requests from Expert."""
return
def publish_diagnostics(params: Any) -> None:
"""Handle textDocument/publishDiagnostics notifications."""
return
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_request("window/workDoneProgress/create", do_nothing)
self.server.on_notification("$/workDoneProgress", work_done_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_request("window/workDoneProgress/create", work_done_progress_create)
self.server.on_notification("textDocument/publishDiagnostics", publish_diagnostics)
log.info("Starting Next LS server process")
log.debug("Starting Expert server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
log.debug("Sending initialize request to Expert")
init_response = self.server.send.initialize(initialize_params)
# Verify server capabilities - be more lenient with Next LS
log.info(f"Next LS capabilities: {list(init_response['capabilities'].keys())}")
# Next LS may not provide all capabilities immediately, so we check for basic ones
# Verify basic server capabilities
assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}"
# Some capabilities might be optional or provided later. This is expected, so we log as info
if "completionProvider" not in init_response["capabilities"]:
log.info("completionProvider not available in initial capabilities")
if "definitionProvider" not in init_response["capabilities"]:
log.info("definitionProvider not available in initial capabilities")
self.server.notify.initialized({})
self.completions_available.set()
# Wait for Next LS to send the specific "Runtime for folder X is ready..." log message
# This is the authoritative signal that Next LS is truly ready for requests
ready_timeout = 180.0
log.info(f"Waiting up to {ready_timeout} seconds for Next LS runtime readiness...")
# Expert needs time to compile the project and build indexes on first run.
# This can take 2-3+ minutes for mid-sized codebases.
# After the first run, subsequent startups are much faster.
ready_timeout = 300.0 # 5 minutes
log.debug(f"Waiting up to {ready_timeout}s for Expert to compile and index...")
if self.server_ready.wait(timeout=ready_timeout):
log.info("Next LS is ready and available for requests")
log.debug("Expert is ready for requests")
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."
log.error(error_msg)
raise RuntimeError(error_msg)
log.warning(f"Expert did not signal readiness within {ready_timeout}s. Proceeding with requests anyway.")
self.server_ready.set() # Mark as ready anyway to allow requests
@@ -0,0 +1,382 @@
"""
Provides F# specific instantiation of the LanguageServer class.
"""
import logging
import os
import pathlib
import shutil
import threading
from pathlib import Path
from overrides import override
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class FSharpLanguageServer(SolidLanguageServer):
"""
Provides F# specific instantiation of the LanguageServer class using Ionide LSP (FsAutoComplete).
Contains various configurations and settings specific to F# development.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates an FSharpLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
fsharp_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings)
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=fsharp_lsp_executable_path, cwd=repository_root_path),
"fsharp",
solidlsp_settings,
)
self.server_ready = threading.Event()
self.initialize_searcher_command_available = threading.Event()
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [
"bin",
"obj",
"packages",
".paket",
"paket-files",
".fake",
".ionide",
]
@classmethod
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str:
"""
Setup runtime dependencies for F# Language Server and return the command to start the server.
"""
# First check if .NET SDK is installed
dotnet_exe = shutil.which("dotnet")
if not dotnet_exe:
raise RuntimeError(
".NET SDK is not installed or not in PATH. Please install .NET SDK 8.0 or later and ensure 'dotnet' is in your PATH."
)
# Verify dotnet version
import subprocess
try:
result = subprocess.run([dotnet_exe, "--version"], capture_output=True, text=True, check=True)
log.info(f"Found .NET SDK version: {result.stdout.strip()}")
except subprocess.CalledProcessError:
raise RuntimeError("Failed to get .NET SDK version. Please ensure .NET SDK is properly installed.")
RuntimeDependencyCollection(
[
RuntimeDependency(
id="fsautocomplete",
description="FsAutoComplete (Ionide F# Language Server)",
command="dotnet tool install --tool-path ./ fsautocomplete",
platform_id="any",
),
]
)
# Install FsAutoComplete if not already installed
fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "fsharp-lsp")
fsautocomplete_path = os.path.join(fsharp_ls_dir, "fsautocomplete")
# Handle Windows executable extension
if os.name == "nt":
fsautocomplete_path += ".exe"
if not os.path.exists(fsautocomplete_path):
log.info(f"FsAutoComplete executable not found at {fsautocomplete_path}. Installing...")
# Ensure the directory exists
os.makedirs(fsharp_ls_dir, exist_ok=True)
# Install FsAutoComplete using dotnet tool install
try:
import subprocess
result = subprocess.run(
[dotnet_exe, "tool", "install", "--tool-path", fsharp_ls_dir, "fsautocomplete"],
cwd=fsharp_ls_dir,
capture_output=True,
text=True,
check=True,
)
log.info("FsAutoComplete installed successfully")
log.debug(f"Installation output: {result.stdout}")
except subprocess.CalledProcessError as e:
log.error(f"Failed to install FsAutoComplete: {e.stderr}")
raise RuntimeError(f"Failed to install FsAutoComplete: {e.stderr}")
if not os.path.exists(fsautocomplete_path):
raise FileNotFoundError(
f"FsAutoComplete executable not found at {fsautocomplete_path}, something went wrong with the installation."
)
# FsAutoComplete uses --lsp flag for LSP mode
return f"{fsautocomplete_path} --adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler"
def _get_initialize_params(self) -> InitializeParams:
"""
Returns the initialize params for the F# Language Server.
"""
root_uri = pathlib.Path(self.repository_root_path).as_uri()
initialize_params = {
"processId": os.getpid(),
"rootPath": self.repository_root_path,
"rootUri": root_uri,
"workspaceFolders": [{"name": "workspace", "uri": root_uri}],
"capabilities": {
"workspace": {
"applyEdit": True,
"workspaceEdit": {"documentChanges": True},
"didChangeConfiguration": {"dynamicRegistration": True},
"didChangeWatchedFiles": {"dynamicRegistration": True},
"symbol": {"dynamicRegistration": True},
"executeCommand": {"dynamicRegistration": True},
"configuration": True,
"workspaceFolders": True,
},
"textDocument": {
"synchronization": {
"dynamicRegistration": True,
"willSave": True,
"willSaveWaitUntil": True,
"didSave": True,
},
"completion": {
"dynamicRegistration": True,
"contextSupport": True,
"completionItem": {
"snippetSupport": True,
"commitCharactersSupport": True,
"documentationFormat": ["markdown", "plaintext"],
"deprecatedSupport": True,
},
},
"hover": {
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"],
},
"signatureHelp": {
"dynamicRegistration": True,
"signatureInformation": {"documentationFormat": ["markdown", "plaintext"]},
},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentHighlight": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"symbolKind": {"valueSet": list(range(1, 26))}, # All SymbolKind values
"hierarchicalDocumentSymbolSupport": True,
},
"codeAction": {
"dynamicRegistration": True,
"codeActionLiteralSupport": {
"codeActionKind": {
"valueSet": [
"",
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports",
]
}
},
},
"codeLens": {"dynamicRegistration": True},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
"onTypeFormatting": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True},
"documentLink": {"dynamicRegistration": True},
"publishDiagnostics": {
"relatedInformation": True,
"versionSupport": False,
"tagSupport": {"valueSet": [1, 2]},
},
"implementation": {"dynamicRegistration": True},
"typeDefinition": {"dynamicRegistration": True},
"colorProvider": {"dynamicRegistration": True},
"foldingRange": {
"dynamicRegistration": True,
"rangeLimit": 5000,
"lineFoldingOnly": True,
},
"declaration": {"dynamicRegistration": True},
"selectionRange": {"dynamicRegistration": True},
},
"window": {
"workDoneProgress": True,
},
},
"initializationOptions": {
# F# specific initialization options
"automaticWorkspaceInit": True,
"abstractClassStubGeneration": True,
"abstractClassStubGenerationObjectIdentifier": "this",
"abstractClassStubGenerationMethodBody": 'failwith "Not Implemented"',
"addFsiWatcher": False,
"codeLenses": {"signature": {"enabled": True}, "references": {"enabled": True}},
"disableInMemoryProjectReferences": False,
"dotNetRoot": self._get_dotnet_root(),
"enableMSBuildProjectGraph": False,
"excludeProjectDirectories": ["paket-files"],
"externalAutocomplete": False,
"fsac": {"attachDebugger": False, "silencedLogs": [], "conserveMemory": False, "netCoreDllPath": ""},
"fsiExtraParameters": [],
"generateBinlog": False,
"interfaceStubGeneration": True,
"interfaceStubGenerationObjectIdentifier": "this",
"interfaceStubGenerationMethodBody": 'failwith "Not Implemented"',
"keywordsAutocomplete": True,
"linter": True,
"pipelineHints": {"enabled": True},
"recordStubGeneration": True,
"recordStubGenerationBody": 'failwith "Not Implemented"',
"resolveNamespaces": True,
"saveOnlyOpenFiles": False,
"showProjectExplorerIn": ["ionide", "solution"],
"simplifyNameAnalyzer": True,
"smartIndent": False,
"suggestGitignore": True,
"suggestSdkScripts": True,
"unionCaseStubGeneration": True,
"unionCaseStubGenerationBody": 'failwith "Not Implemented"',
"unusedDeclarationsAnalyzer": True,
"unusedOpensAnalyzer": True,
"verboseLogging": False,
"workspaceModePeekDeepLevel": 2,
"workspacePath": self.repository_root_path,
},
"trace": "off",
}
return initialize_params # type: ignore
def _get_dotnet_root(self) -> str:
"""
Get the .NET root directory.
"""
dotnet_exe = shutil.which("dotnet")
if dotnet_exe:
# Try to get the installation path
try:
import subprocess
result = subprocess.run([dotnet_exe, "--info"], capture_output=True, text=True, check=True)
lines = result.stdout.split("\n")
for line in lines:
if "Base Path:" in line or "Base path:" in line:
base_path = line.split(":", 1)[1].strip()
# Get the parent directory (remove 'sdk/version' part)
return str(Path(base_path).parent.parent)
except (subprocess.CalledProcessError, Exception):
pass
# Fallback: use the directory containing dotnet executable
if dotnet_exe:
return str(Path(dotnet_exe).parent)
return ""
def _start_server(self) -> None:
"""
Start the F# Language Server with custom handlers.
"""
def handle_window_log_message(params: dict) -> None:
"""Handle window/logMessage from the LSP server."""
message = params.get("message", "")
message_type = params.get("type", 1)
# Map LSP log levels to Python logging levels
level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG}
level = level_map.get(message_type, logging.INFO)
log.log(level, f"FsAutoComplete: {message}")
def handle_window_show_message(params: dict) -> None:
"""Handle window/showMessage from the LSP server."""
message = params.get("message", "")
message_type = params.get("type", 1)
# Map LSP message types to Python logging levels
level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG}
level = level_map.get(message_type, logging.INFO)
log.log(level, f"FsAutoComplete Message: {message}")
def handle_workspace_configuration(params: dict) -> list:
"""Handle workspace/configuration requests from the LSP server."""
# Return empty configuration for now
items = params.get("items", [])
return [None] * len(items)
def handle_client_register_capability(params: dict) -> None:
"""Handle client/registerCapability requests from the LSP server."""
# For now, just acknowledge the registration
return
def handle_client_unregister_capability(params: dict) -> None:
"""Handle client/unregisterCapability requests from the LSP server."""
# For now, just acknowledge the unregistration
return
def handle_work_done_progress_create(params: dict) -> None:
"""Handle window/workDoneProgress/create requests from the LSP server."""
# Just acknowledge the request - we don't need to track progress for now
return
# Register custom handlers
self.server.on_notification("window/logMessage", handle_window_log_message)
self.server.on_notification("window/showMessage", handle_window_show_message)
self.server.on_request("workspace/configuration", handle_workspace_configuration)
self.server.on_request("client/registerCapability", handle_client_register_capability)
self.server.on_request("client/unregisterCapability", handle_client_unregister_capability)
self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create)
log.info("Starting FsAutoComplete F# language server process")
try:
self.server.start()
except Exception as e:
log.error(f"Failed to start F# language server process: {e}")
raise SolidLSPException(f"Failed to start F# language server: {e}")
# Send initialization
initialize_params = self._get_initialize_params()
log.info("Sending initialize request to F# language server")
try:
self.server.send.initialize(initialize_params)
log.debug("Received initialize response from F# language server")
except Exception as e:
raise SolidLSPException(f"Failed to initialize F# language server for {self.repository_root_path}: {e}") from e
# Complete initialization
self.server.notify.initialized({})
log.info("F# language server initialized successfully")
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
"""
F# projects can be large and may need more time for cross-file analysis.
"""
return 15.0 # 15 seconds should be sufficient for most F# projects
@@ -0,0 +1,325 @@
"""
Provides PowerShell specific instantiation of the LanguageServer class using PowerShell Editor Services.
Contains various configurations and settings specific to PowerShell scripting.
"""
import logging
import os
import pathlib
import platform
import shutil
import tempfile
import threading
import zipfile
from pathlib import Path
import requests
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
# PowerShell Editor Services version to download
PSES_VERSION = "4.4.0"
class PowerShellLanguageServer(SolidLanguageServer):
"""
Provides PowerShell specific instantiation of the LanguageServer class using PowerShell Editor Services.
Contains various configurations and settings specific to PowerShell scripting.
"""
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For PowerShell projects, ignore common build/output directories
return super().is_ignored_dirname(dirname) or dirname in [
"bin",
"obj",
".vscode",
"TestResults",
"Output",
]
@staticmethod
def _get_pwsh_path() -> str | None:
"""Get the path to PowerShell Core (pwsh) executable."""
# Check if pwsh is in PATH
pwsh = shutil.which("pwsh")
if pwsh:
return pwsh
# Check common installation locations
home = Path.home()
system = platform.system()
possible_paths: list[Path] = []
if system == "Windows":
possible_paths = [
Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "PowerShell" / "7" / "pwsh.exe",
Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "PowerShell" / "7-preview" / "pwsh.exe",
home / "AppData" / "Local" / "Microsoft" / "PowerShell" / "pwsh.exe",
]
elif system == "Darwin":
possible_paths = [
Path("/usr/local/bin/pwsh"),
Path("/opt/homebrew/bin/pwsh"),
home / ".dotnet" / "tools" / "pwsh",
]
else: # Linux
possible_paths = [
Path("/usr/bin/pwsh"),
Path("/usr/local/bin/pwsh"),
Path("/opt/microsoft/powershell/7/pwsh"),
home / ".dotnet" / "tools" / "pwsh",
]
for path in possible_paths:
if path.exists():
return str(path)
return None
@classmethod
def _get_pses_path(cls, solidlsp_settings: SolidLSPSettings) -> str | None:
"""Get the path to PowerShell Editor Services installation."""
install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell"
start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1"
if start_script.exists():
return str(start_script)
return None
@classmethod
def _download_pses(cls, solidlsp_settings: SolidLSPSettings) -> str:
"""Download and install PowerShell Editor Services."""
download_url = (
f"https://github.com/PowerShell/PowerShellEditorServices/releases/download/v{PSES_VERSION}/PowerShellEditorServices.zip"
)
# Create installation directory
install_dir = Path(cls.ls_resources_dir(solidlsp_settings)) / "powershell"
install_dir.mkdir(parents=True, exist_ok=True)
# Download the file
log.info(f"Downloading PowerShell Editor Services from {download_url}...")
response = requests.get(download_url, stream=True, timeout=120)
response.raise_for_status()
# Save the zip file
zip_path = install_dir / "PowerShellEditorServices.zip"
with open(zip_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
log.info(f"Extracting PowerShell Editor Services to {install_dir}...")
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(install_dir)
# Clean up zip file
zip_path.unlink()
start_script = install_dir / "PowerShellEditorServices" / "Start-EditorServices.ps1"
if not start_script.exists():
raise RuntimeError(f"Failed to find Start-EditorServices.ps1 after extraction at {start_script}")
log.info(f"PowerShell Editor Services installed at: {install_dir}")
return str(start_script)
@classmethod
def _setup_runtime_dependency(cls, solidlsp_settings: SolidLSPSettings) -> tuple[str, str, str]:
"""
Check if required PowerShell runtime dependencies are available.
Downloads PowerShell Editor Services if not present.
Returns:
tuple: (pwsh_path, start_script_path, bundled_modules_path)
"""
# Check for PowerShell Core
pwsh_path = cls._get_pwsh_path()
if not pwsh_path:
raise RuntimeError(
"PowerShell Core (pwsh) is not installed or not in PATH. "
"Please install PowerShell 7+ from https://github.com/PowerShell/PowerShell"
)
# Check for PowerShell Editor Services
pses_path = cls._get_pses_path(solidlsp_settings)
if not pses_path:
log.info("PowerShell Editor Services not found. Downloading...")
pses_path = cls._download_pses(solidlsp_settings)
# The bundled modules path is the directory containing PowerShellEditorServices
bundled_modules_path = str(Path(pses_path).parent)
return pwsh_path, pses_path, bundled_modules_path
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
pwsh_path, pses_path, bundled_modules_path = self._setup_runtime_dependency(solidlsp_settings)
# Create a temp directory for PSES logs and session details
pses_temp_dir = Path(tempfile.gettempdir()) / "solidlsp_pses"
pses_temp_dir.mkdir(parents=True, exist_ok=True)
log_path = pses_temp_dir / "pses.log"
session_details_path = pses_temp_dir / "session.json"
# Build the command to start PowerShell Editor Services in stdio mode
# PSES requires several parameters beyond just -Stdio
# Using list format for robust argument handling - the PowerShell command
# after -Command must be a single string element
pses_command = (
f"& '{pses_path}' "
f"-HostName 'SolidLSP' "
f"-HostProfileId 'solidlsp' "
f"-HostVersion '1.0.0' "
f"-BundledModulesPath '{bundled_modules_path}' "
f"-LogPath '{log_path}' "
f"-LogLevel 'Information' "
f"-SessionDetailsPath '{session_details_path}' "
f"-Stdio"
)
cmd: list[str] = [
pwsh_path,
"-NoLogo",
"-NoProfile",
"-Command",
pses_command,
]
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path),
"powershell",
solidlsp_settings,
)
self.server_ready = threading.Event()
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the PowerShell Editor Services.
"""
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,
},
},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"signatureHelp": {
"dynamicRegistration": True,
"signatureInformation": {
"documentationFormat": ["markdown", "plaintext"],
"parameterInformation": {"labelOffsetSupport": True},
},
},
"codeAction": {"dynamicRegistration": True},
"formatting": {"dynamicRegistration": True},
"rangeFormatting": {"dynamicRegistration": True},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
"configuration": 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 # type: ignore[return-value]
def _start_server(self) -> None:
"""
Starts the PowerShell Editor Services, waits for the server to be ready.
"""
self._dynamic_capabilities: set[str] = set()
def register_capability_handler(params: dict) -> None:
"""Handle dynamic capability registration from PSES."""
registrations = params.get("registrations", [])
for reg in registrations:
method = reg.get("method", "")
log.info(f"PSES registered dynamic capability: {method}")
self._dynamic_capabilities.add(method)
# Mark server ready when we get document symbol registration
if method == "textDocument/documentSymbol":
self.server_ready.set()
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
# Check for PSES ready signals
message_text = msg.get("message", "")
if "started" in message_text.lower() or "ready" in message_text.lower():
log.info("PowerShell Editor Services ready signal detected")
self.server_ready.set()
self.completions_available.set()
def do_nothing(params: dict) -> None:
return
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.server.on_notification("powerShell/executionStatusChanged", do_nothing)
log.info("Starting PowerShell Editor Services process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
log.info(f"Received initialize response from PowerShell server: {init_response}")
# Verify server capabilities - PSES uses dynamic capability registration
# so we check for either static or dynamic capabilities
capabilities = init_response.get("capabilities", {})
log.info(f"Server capabilities: {capabilities}")
# Send initialized notification to trigger dynamic capability registration
self.server.notify.initialized({})
# Wait for server readiness with timeout
log.info("Waiting for PowerShell Editor Services to be ready...")
if not self.server_ready.wait(timeout=10.0):
# Fallback: assume server is ready after timeout
log.info("Timeout waiting for PSES ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("PowerShell Editor Services initialization complete")
@@ -61,11 +61,12 @@ class ScalaLanguageServer(SolidLanguageServer):
os.makedirs(metals_home, exist_ok=True)
metals_executable = os.path.join(metals_home, "metals")
coursier_command_path = shutil.which("coursier")
assert coursier_command_path is not None, "coursier is not installed or not in PATH."
cs_command_path = shutil.which("cs")
assert cs_command_path is not None or coursier_command_path is not None, "coursier is not installed or not in PATH."
if not os.path.exists(metals_executable):
if not cs_command_path:
assert coursier_command_path is not None
log.info("'cs' command not found. Trying to install it using 'coursier'.")
try:
log.info("Running 'coursier setup --yes' to install 'cs'...")
@@ -12,6 +12,7 @@ from typing import Any, cast
from overrides import override
from sensai.util.logging import LogTime
from solidlsp import ls_types
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_utils import PlatformId, PlatformUtils
@@ -39,9 +40,32 @@ if not PlatformUtils.get_platform_id().value.startswith("win"):
pass
def prefer_non_node_modules_definition(definitions: list[ls_types.Location]) -> ls_types.Location:
"""
Select the preferred definition, preferring source files over type definitions.
TypeScript language servers often return both type definitions (.d.ts files
in node_modules) and source definitions. This function prefers:
1. Files not in node_modules
2. Falls back to first definition if all are in node_modules
:param definitions: A non-empty list of definition locations.
:return: The preferred definition location.
"""
for d in definitions:
rel_path = d.get("relativePath", "")
if rel_path and "node_modules" not in rel_path:
return d
return definitions[0]
class TypeScriptLanguageServer(SolidLanguageServer):
"""
Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript.
You can pass the following entries in ls_specific_settings["typescript"]:
- typescript_version: Version of TypeScript to install (default: "5.9.3")
- typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3")
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
@@ -91,18 +115,23 @@ class TypeScriptLanguageServer(SolidLanguageServer):
]
assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment"
# Get version settings from ls_specific_settings or use defaults
language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance())
typescript_version = language_specific_config.get("typescript_version", "5.9.3")
typescript_language_server_version = language_specific_config.get("typescript_language_server_version", "5.1.3")
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="typescript",
description="typescript package",
command=["npm", "install", "--prefix", "./", "typescript@5.5.4"],
command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"],
platform_id="any",
),
RuntimeDependency(
id="typescript-language-server",
description="typescript-language-server package",
command=["npm", "install", "--prefix", "./", "typescript-language-server@4.3.3"],
command=["npm", "install", "--prefix", "./", f"typescript-language-server@{typescript_language_server_version}"],
platform_id="any",
),
]
@@ -114,19 +143,39 @@ class TypeScriptLanguageServer(SolidLanguageServer):
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."
# 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."
# Install typescript and typescript-language-server if not already installed
# Install typescript and typescript-language-server if not already installed or version mismatch
tsserver_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "ts-lsp")
tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server")
# Check if installation is needed based on executable AND version
version_file = os.path.join(tsserver_ls_dir, ".installed_version")
expected_version = f"{typescript_version}_{typescript_language_server_version}"
needs_install = False
if not os.path.exists(tsserver_executable_path):
log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...")
log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}.")
needs_install = True
elif os.path.exists(version_file):
with open(version_file) as f:
installed_version = f.read().strip()
if installed_version != expected_version:
log.info(
f"TypeScript Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..."
)
needs_install = True
else:
# No version file exists, assume old installation needs refresh
log.info("TypeScript Language Server version file not found. Reinstalling to ensure correct version...")
needs_install = True
if needs_install:
log.info("Installing TypeScript Language Server dependencies...")
with LogTime("Installation of TypeScript language server dependencies", logger=log):
deps.install(tsserver_ls_dir)
# Write version marker file
with open(version_file, "w") as f:
f.write(expected_version)
log.info("TypeScript language server dependencies installed successfully")
if not os.path.exists(tsserver_executable_path):
raise FileNotFoundError(
@@ -134,8 +183,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
)
return [tsserver_executable_path, "--stdio"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the TypeScript Language Server.
"""
@@ -156,6 +204,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"signatureHelp": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
"workspace": {
"workspaceFolders": True,
@@ -252,3 +301,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 1
@override
def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location:
return prefer_non_node_modules_definition(definitions)
@@ -0,0 +1,809 @@
"""
Vue Language Server implementation using @vue/language-server (Volar) with companion TypeScript LS.
Operates in hybrid mode: Vue LS handles .vue files, TypeScript LS handles .ts/.js files.
"""
import logging
import os
import pathlib
import shutil
import threading
from pathlib import Path
from time import sleep
from typing import Any
from overrides import override
from solidlsp import ls_types
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection
from solidlsp.language_servers.typescript_language_server import (
TypeScriptLanguageServer,
prefer_non_node_modules_definition,
)
from solidlsp.ls import LSPFileBuffer, SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_types import Location
from solidlsp.ls_utils import PathUtils
from solidlsp.lsp_protocol_handler import lsp_types
from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, InitializeParams, SymbolInformation
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class VueTypeScriptServer(TypeScriptLanguageServer):
"""TypeScript LS configured with @vue/typescript-plugin for Vue file support."""
_pending_ts_ls_executable: list[str] | None = None
@classmethod
@override
def get_language_enum_instance(cls) -> Language:
"""Return TYPESCRIPT since this is a TypeScript language server variant.
Note: VueTypeScriptServer is a companion server that uses TypeScript's language server
with the Vue TypeScript plugin. It reports as TYPESCRIPT to maintain compatibility
with the TypeScript language server infrastructure.
"""
return Language.TYPESCRIPT
@classmethod
@override
def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]:
if cls._pending_ts_ls_executable is not None:
return cls._pending_ts_ls_executable
return ["typescript-language-server", "--stdio"]
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
"""Return the correct language ID for files.
Vue files must be opened with language ID "vue" for the @vue/typescript-plugin
to process them correctly. The plugin is configured with "languages": ["vue"]
in the initialization options.
"""
ext = os.path.splitext(relative_file_path)[1].lower()
if ext == ".vue":
return "vue"
elif ext in (".ts", ".tsx", ".mts", ".cts"):
return "typescript"
elif ext in (".js", ".jsx", ".mjs", ".cjs"):
return "javascript"
else:
return "typescript"
def __init__(
self,
config: LanguageServerConfig,
repository_root_path: str,
solidlsp_settings: SolidLSPSettings,
vue_plugin_path: str,
tsdk_path: str,
ts_ls_executable_path: list[str],
):
self._vue_plugin_path = vue_plugin_path
self._custom_tsdk_path = tsdk_path
VueTypeScriptServer._pending_ts_ls_executable = ts_ls_executable_path
super().__init__(config, repository_root_path, solidlsp_settings)
VueTypeScriptServer._pending_ts_ls_executable = None
@override
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
params = super()._get_initialize_params(repository_absolute_path)
params["initializationOptions"] = {
"plugins": [
{
"name": "@vue/typescript-plugin",
"location": self._vue_plugin_path,
"languages": ["vue"],
}
],
"tsserver": {
"path": self._custom_tsdk_path,
},
}
if "workspace" in params["capabilities"]:
params["capabilities"]["workspace"]["executeCommand"] = {"dynamicRegistration": True}
return params
@override
def _start_server(self) -> None:
def workspace_configuration_handler(params: dict) -> list:
items = params.get("items", [])
return [{} for _ in items]
self.server.on_request("workspace/configuration", workspace_configuration_handler)
super()._start_server()
class VueLanguageServer(SolidLanguageServer):
"""
Language server for Vue Single File Components using @vue/language-server (Volar) with companion TypeScript LS.
You can pass the following entries in ls_specific_settings["vue"]:
- vue_language_server_version: Version of @vue/language-server to install (default: "3.1.5")
Note: TypeScript versions are configured via ls_specific_settings["typescript"]:
- typescript_version: Version of TypeScript to install (default: "5.9.3")
- typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3")
"""
TS_SERVER_READY_TIMEOUT = 5.0
VUE_SERVER_READY_TIMEOUT = 3.0
# Windows requires more time due to slower I/O and process operations.
VUE_INDEXING_WAIT_TIME = 4.0 if os.name == "nt" else 2.0
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
vue_lsp_executable_path, self.tsdk_path, self._ts_ls_cmd = self._setup_runtime_dependencies(config, solidlsp_settings)
self._vue_ls_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "vue-lsp")
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=vue_lsp_executable_path, cwd=repository_root_path),
"vue",
solidlsp_settings,
)
self.server_ready = threading.Event()
self.initialize_searcher_command_available = threading.Event()
self._ts_server: VueTypeScriptServer | None = None
self._ts_server_started = False
self._vue_files_indexed = False
self._indexed_vue_file_uris: list[str] = []
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [
"node_modules",
"dist",
"build",
"coverage",
".nuxt",
".output",
]
@override
def _get_language_id_for_file(self, relative_file_path: str) -> str:
ext = os.path.splitext(relative_file_path)[1].lower()
if ext == ".vue":
return "vue"
elif ext in (".ts", ".tsx", ".mts", ".cts"):
return "typescript"
elif ext in (".js", ".jsx", ".mjs", ".cjs"):
return "javascript"
else:
return "vue"
def _is_typescript_file(self, file_path: str) -> bool:
ext = os.path.splitext(file_path)[1].lower()
return ext in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
def _find_all_vue_files(self) -> list[str]:
vue_files = []
repo_path = Path(self.repository_root_path)
for vue_file in repo_path.rglob("*.vue"):
try:
relative_path = str(vue_file.relative_to(repo_path))
if "node_modules" not in relative_path and not relative_path.startswith("."):
vue_files.append(relative_path)
except Exception as e:
log.debug(f"Error processing Vue file {vue_file}: {e}")
return vue_files
def _ensure_vue_files_indexed_on_ts_server(self) -> None:
if self._vue_files_indexed:
return
assert self._ts_server is not None
log.info("Indexing .vue files on TypeScript server for cross-file references")
vue_files = self._find_all_vue_files()
log.debug(f"Found {len(vue_files)} .vue files to index")
for vue_file in vue_files:
try:
with self._ts_server.open_file(vue_file) as file_buffer:
file_buffer.ref_count += 1
self._indexed_vue_file_uris.append(file_buffer.uri)
except Exception as e:
log.debug(f"Failed to open {vue_file} on TS server: {e}")
self._vue_files_indexed = True
log.info("Vue file indexing on TypeScript server complete")
sleep(self._get_vue_indexing_wait_time())
log.debug("Wait period after Vue file indexing complete")
def _get_vue_indexing_wait_time(self) -> float:
return self.VUE_INDEXING_WAIT_TIME
def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None:
uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))
request_params = {
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
"context": {"includeDeclaration": False},
}
return self.server.send.references(request_params) # type: ignore[arg-type]
def _send_ts_references_request(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
assert self._ts_server is not None
uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path))
request_params = {
"textDocument": {"uri": uri},
"position": {"line": line, "character": column},
"context": {"includeDeclaration": True},
}
with self._ts_server.open_file(relative_file_path):
response = self._ts_server.handler.send.references(request_params) # type: ignore[arg-type]
result: list[ls_types.Location] = []
if response is not None:
for item in response:
abs_path = PathUtils.uri_to_path(item["uri"])
if not Path(abs_path).is_relative_to(self.repository_root_path):
log.debug(f"Found reference outside repository: {abs_path}, skipping")
continue
rel_path = Path(abs_path).relative_to(self.repository_root_path)
if self.is_ignored_path(str(rel_path)):
log.debug(f"Ignoring reference in {rel_path}")
continue
new_item: dict = {}
new_item.update(item) # type: ignore[arg-type]
new_item["absolutePath"] = str(abs_path)
new_item["relativePath"] = str(rel_path)
result.append(ls_types.Location(**new_item)) # type: ignore
return result
def request_file_references(self, relative_file_path: str) -> list:
if not self.server_started:
log.error("request_file_references called before Language Server started")
raise SolidLSPException("Language Server not started")
absolute_file_path = os.path.join(self.repository_root_path, relative_file_path)
uri = PathUtils.path_to_uri(absolute_file_path)
request_params = {"textDocument": {"uri": uri}}
log.info(f"Sending volar/client/findFileReference request for {relative_file_path}")
log.info(f"Request URI: {uri}")
log.info(f"Request params: {request_params}")
try:
with self.open_file(relative_file_path):
log.debug(f"Sending volar/client/findFileReference for {relative_file_path}")
log.debug(f"Request params: {request_params}")
response = self.server.send_request("volar/client/findFileReference", request_params)
log.debug(f"Received response type: {type(response)}")
log.info(f"Received file references response: {response}")
log.info(f"Response type: {type(response)}")
if response is None:
log.debug(f"No file references found for {relative_file_path}")
return []
# Response should be an array of Location objects
if not isinstance(response, list):
log.warning(f"Unexpected response format from volar/client/findFileReference: {type(response)}")
return []
ret: list[Location] = []
for item in response:
if not isinstance(item, dict) or "uri" not in item:
log.debug(f"Skipping invalid location item: {item}")
continue
abs_path = PathUtils.uri_to_path(item["uri"]) # type: ignore[arg-type]
if not Path(abs_path).is_relative_to(self.repository_root_path):
log.warning(f"Found file reference outside repository: {abs_path}, skipping")
continue
rel_path = Path(abs_path).relative_to(self.repository_root_path)
if self.is_ignored_path(str(rel_path)):
log.debug(f"Ignoring file reference in {rel_path}")
continue
new_item: dict = {}
new_item.update(item) # type: ignore[arg-type]
new_item["absolutePath"] = str(abs_path)
new_item["relativePath"] = str(rel_path)
ret.append(Location(**new_item)) # type: ignore
log.debug(f"Found {len(ret)} file references for {relative_file_path}")
return ret
except Exception as e:
log.warning(f"Error requesting file references for {relative_file_path}: {e}")
return []
@override
def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
if not self.server_started:
log.error("request_references called before Language Server started")
raise SolidLSPException("Language Server not started")
if not self._has_waited_for_cross_file_references:
sleep(self._get_wait_time_for_cross_file_referencing())
self._has_waited_for_cross_file_references = True
self._ensure_vue_files_indexed_on_ts_server()
symbol_refs = self._send_ts_references_request(relative_file_path, line=line, column=column)
if relative_file_path.endswith(".vue"):
log.info(f"Attempting to find file-level references for Vue component {relative_file_path}")
file_refs = self.request_file_references(relative_file_path)
log.info(f"file_refs result: {len(file_refs)} references found")
seen = set()
for ref in symbol_refs:
key = (ref["uri"], ref["range"]["start"]["line"], ref["range"]["start"]["character"])
seen.add(key)
for file_ref in file_refs:
key = (file_ref["uri"], file_ref["range"]["start"]["line"], file_ref["range"]["start"]["character"])
if key not in seen:
symbol_refs.append(file_ref)
seen.add(key)
log.info(f"Total references for {relative_file_path}: {len(symbol_refs)} (symbol refs + file refs, deduplicated)")
return symbol_refs
@override
def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]:
if not self.server_started:
log.error("request_definition called before Language Server started")
raise SolidLSPException("Language Server not started")
assert self._ts_server is not None
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_definition(relative_file_path, line, column)
@override
def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None:
if not self.server_started:
log.error("request_rename_symbol_edit called before Language Server started")
raise SolidLSPException("Language Server not started")
assert self._ts_server is not None
with self._ts_server.open_file(relative_file_path):
return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name)
@classmethod
def _setup_runtime_dependencies(
cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings
) -> tuple[list[str], str, list[str]]:
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."
# Get TypeScript version settings from TypeScript language server settings
typescript_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT)
typescript_version = typescript_config.get("typescript_version", "5.9.3")
typescript_language_server_version = typescript_config.get("typescript_language_server_version", "5.1.3")
vue_config = solidlsp_settings.get_ls_specific_settings(Language.VUE)
vue_language_server_version = vue_config.get("vue_language_server_version", "3.1.5")
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="vue-language-server",
description="Vue language server package (Volar)",
command=["npm", "install", "--prefix", "./", f"@vue/language-server@{vue_language_server_version}"],
platform_id="any",
),
RuntimeDependency(
id="typescript",
description="TypeScript (required for tsdk)",
command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"],
platform_id="any",
),
RuntimeDependency(
id="typescript-language-server",
description="TypeScript language server (for Vue LS 3.x tsserver forwarding)",
command=[
"npm",
"install",
"--prefix",
"./",
f"typescript-language-server@{typescript_language_server_version}",
],
platform_id="any",
),
]
)
vue_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vue-lsp")
vue_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "vue-language-server")
ts_ls_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "typescript-language-server")
if os.name == "nt":
vue_executable_path += ".cmd"
ts_ls_executable_path += ".cmd"
tsdk_path = os.path.join(vue_ls_dir, "node_modules", "typescript", "lib")
# Check if installation is needed based on executables AND version
version_file = os.path.join(vue_ls_dir, ".installed_version")
expected_version = f"{vue_language_server_version}_{typescript_version}_{typescript_language_server_version}"
needs_install = False
if not os.path.exists(vue_executable_path) or not os.path.exists(ts_ls_executable_path):
log.info("Vue/TypeScript Language Server executables not found.")
needs_install = True
elif os.path.exists(version_file):
with open(version_file) as f:
installed_version = f.read().strip()
if installed_version != expected_version:
log.info(
f"Vue Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..."
)
needs_install = True
else:
# No version file exists, assume old installation needs refresh
log.info("Vue Language Server version file not found. Reinstalling to ensure correct version...")
needs_install = True
if needs_install:
log.info("Installing Vue/TypeScript Language Server dependencies...")
deps.install(vue_ls_dir)
# Write version marker file
with open(version_file, "w") as f:
f.write(expected_version)
log.info("Vue language server dependencies installed successfully")
if not os.path.exists(vue_executable_path):
raise FileNotFoundError(
f"vue-language-server executable not found at {vue_executable_path}, something went wrong with the installation."
)
if not os.path.exists(ts_ls_executable_path):
raise FileNotFoundError(
f"typescript-language-server executable not found at {ts_ls_executable_path}, something went wrong with the installation."
)
return [vue_executable_path, "--stdio"], tsdk_path, [ts_ls_executable_path, "--stdio"]
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
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, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
"signatureHelp": {"dynamicRegistration": True},
"codeAction": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": 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": {
"vue": {
"hybridMode": True,
},
"typescript": {
"tsdk": self.tsdk_path,
},
},
}
return initialize_params # type: ignore
def _start_typescript_server(self) -> None:
try:
vue_ts_plugin_path = os.path.join(self._vue_ls_dir, "node_modules", "@vue", "typescript-plugin")
ts_config = LanguageServerConfig(
code_language=Language.TYPESCRIPT,
trace_lsp_communication=False,
)
log.info("Creating companion VueTypeScriptServer")
self._ts_server = VueTypeScriptServer(
config=ts_config,
repository_root_path=self.repository_root_path,
solidlsp_settings=self._solidlsp_settings,
vue_plugin_path=vue_ts_plugin_path,
tsdk_path=self.tsdk_path,
ts_ls_executable_path=self._ts_ls_cmd,
)
log.info("Starting companion TypeScript server")
self._ts_server.start()
log.info("Waiting for companion TypeScript server to be ready...")
if not self._ts_server.server_ready.wait(timeout=self.TS_SERVER_READY_TIMEOUT):
log.warning(
f"Timeout waiting for companion TypeScript server to be ready after {self.TS_SERVER_READY_TIMEOUT} seconds, proceeding anyway"
)
self._ts_server.server_ready.set()
self._ts_server_started = True
log.info("Companion TypeScript server ready")
except Exception as e:
log.error(f"Error starting TypeScript server: {e}")
self._ts_server = None
self._ts_server_started = False
raise
def _forward_tsserver_request(self, method: str, params: dict) -> Any:
if self._ts_server is None:
log.error("Cannot forward tsserver request - TypeScript server not started")
return None
try:
execute_params: ExecuteCommandParams = {
"command": "typescript.tsserverRequest",
"arguments": [method, params, {"isAsync": True, "lowPriority": True}],
}
result = self._ts_server.handler.send.execute_command(execute_params)
log.debug(f"TypeScript server raw response for {method}: {result}")
if isinstance(result, dict) and "body" in result:
return result["body"]
return result
except Exception as e:
log.error(f"Error forwarding tsserver request {method}: {e}")
return None
def _cleanup_indexed_vue_files(self) -> None:
if not self._indexed_vue_file_uris or self._ts_server is None:
return
log.debug(f"Cleaning up {len(self._indexed_vue_file_uris)} indexed Vue files")
for uri in self._indexed_vue_file_uris:
try:
if uri in self._ts_server.open_file_buffers:
file_buffer = self._ts_server.open_file_buffers[uri]
file_buffer.ref_count -= 1
if file_buffer.ref_count == 0:
self._ts_server.server.notify.did_close_text_document({"textDocument": {"uri": uri}})
del self._ts_server.open_file_buffers[uri]
log.debug(f"Closed indexed Vue file: {uri}")
except Exception as e:
log.debug(f"Error closing indexed Vue file {uri}: {e}")
self._indexed_vue_file_uris.clear()
def _stop_typescript_server(self) -> None:
if self._ts_server is not None:
try:
log.info("Stopping companion TypeScript server")
self._ts_server.stop()
except Exception as e:
log.warning(f"Error stopping TypeScript server: {e}")
finally:
self._ts_server = None
self._ts_server_started = False
@override
def _start_server(self) -> None:
self._start_typescript_server()
def register_capability_handler(params: dict) -> None:
assert "registrations" in params
for registration in params["registrations"]:
if registration["method"] == "workspace/executeCommand":
self.initialize_searcher_command_available.set()
return
def configuration_handler(params: dict) -> list:
items = params.get("items", [])
return [{} for _ in items]
def do_nothing(params: dict) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
message_text = msg.get("message", "")
if "initialized" in message_text.lower() or "ready" in message_text.lower():
log.info("Vue language server ready signal detected")
self.server_ready.set()
self.completions_available.set()
def tsserver_request_notification_handler(params: list) -> None:
try:
if params and len(params) > 0 and len(params[0]) >= 2:
request_id = params[0][0]
method = params[0][1]
method_params = params[0][2] if len(params[0]) > 2 else {}
log.debug(f"Received tsserver/request: id={request_id}, method={method}")
if method == "_vue:projectInfo":
file_path = method_params.get("file", "")
tsconfig_path = self._find_tsconfig_for_file(file_path)
result = {"configFileName": tsconfig_path} if tsconfig_path else None
response = [[request_id, result]]
self.server.notify.send_notification("tsserver/response", response)
log.debug(f"Sent tsserver/response for projectInfo: {tsconfig_path}")
else:
result = self._forward_tsserver_request(method, method_params)
response = [[request_id, result]]
self.server.notify.send_notification("tsserver/response", response)
log.debug(f"Forwarded tsserver/response for {method}: {result}")
else:
log.warning(f"Unexpected tsserver/request params format: {params}")
except Exception as e:
log.error(f"Error handling tsserver/request: {e}")
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_request("workspace/configuration", configuration_handler)
self.server.on_notification("tsserver/request", tsserver_request_notification_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting Vue server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
log.debug(f"Received initialize response from Vue server: {init_response}")
assert init_response["capabilities"]["textDocumentSync"] in [1, 2]
self.server.notify.initialized({})
log.info("Waiting for Vue language server to be ready...")
if not self.server_ready.wait(timeout=self.VUE_SERVER_READY_TIMEOUT):
log.info("Timeout waiting for Vue server ready signal, proceeding anyway")
self.server_ready.set()
self.completions_available.set()
else:
log.info("Vue server initialization complete")
def _find_tsconfig_for_file(self, file_path: str) -> str | None:
if not file_path:
tsconfig_path = os.path.join(self.repository_root_path, "tsconfig.json")
return tsconfig_path if os.path.exists(tsconfig_path) else None
current_dir = os.path.dirname(file_path)
repo_root = os.path.abspath(self.repository_root_path)
while current_dir and current_dir.startswith(repo_root):
tsconfig_path = os.path.join(current_dir, "tsconfig.json")
if os.path.exists(tsconfig_path):
return tsconfig_path
parent = os.path.dirname(current_dir)
if parent == current_dir:
break
current_dir = parent
tsconfig_path = os.path.join(repo_root, "tsconfig.json")
return tsconfig_path if os.path.exists(tsconfig_path) else None
@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
return 5.0
@override
def stop(self, shutdown_timeout: float = 5.0) -> None:
self._cleanup_indexed_vue_files()
self._stop_typescript_server()
super().stop(shutdown_timeout)
@override
def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location:
return prefer_non_node_modules_definition(definitions)
@override
def _request_document_symbols(
self, relative_file_path: str, file_data: LSPFileBuffer | None
) -> list[SymbolInformation] | list[DocumentSymbol] | None:
"""
Override to filter out shorthand property references in Vue files.
In Vue, when using shorthand syntax in defineExpose like `defineExpose({ pressCount })`,
the Vue LSP returns both:
- The Variable definition (e.g., `const pressCount = ref(0)`)
- A Property symbol for the shorthand reference (e.g., `pressCount` in defineExpose)
This causes duplicate symbols with the same name, which breaks symbol lookup.
We filter out Property symbols that have a matching Variable with the same name
at a different location (the definition), keeping only the definition.
"""
symbols = super()._request_document_symbols(relative_file_path, file_data)
if symbols is None or len(symbols) == 0:
return symbols
# Only process DocumentSymbol format (hierarchical symbols with children)
# SymbolInformation format doesn't have the same issue
if not isinstance(symbols[0], dict) or "range" not in symbols[0]:
return symbols
return self._filter_shorthand_property_duplicates(symbols)
def _filter_shorthand_property_duplicates(
self, symbols: list[DocumentSymbol] | list[SymbolInformation]
) -> list[DocumentSymbol] | list[SymbolInformation]:
"""
Filter out Property symbols that have a matching Variable symbol with the same name.
This handles Vue's shorthand property syntax in defineExpose, where the same
identifier appears as both a Variable definition and a Property reference.
"""
VARIABLE_KIND = 13 # SymbolKind.Variable
PROPERTY_KIND = 7 # SymbolKind.Property
def filter_symbols(syms: list[dict]) -> list[dict]:
# Collect all Variable symbol names with their line numbers
variable_names: dict[str, set[int]] = {}
for sym in syms:
if sym.get("kind") == VARIABLE_KIND:
name = sym.get("name", "")
line = sym.get("range", {}).get("start", {}).get("line", -1)
if name not in variable_names:
variable_names[name] = set()
variable_names[name].add(line)
# Filter: keep symbols that are either:
# 1. Not a Property, or
# 2. A Property without a matching Variable name at a different location
filtered = []
for sym in syms:
name = sym.get("name", "")
kind = sym.get("kind")
line = sym.get("range", {}).get("start", {}).get("line", -1)
# If it's a Property with a matching Variable name at a DIFFERENT line, skip it
if kind == PROPERTY_KIND and name in variable_names:
# Check if there's a Variable definition at a different line
var_lines = variable_names[name]
if any(var_line != line for var_line in var_lines):
# This is a shorthand reference, skip it
log.debug(
f"Filtering shorthand property reference '{name}' at line {line} "
f"(Variable definition exists at line(s) {var_lines})"
)
continue
# Recursively filter children
children = sym.get("children", [])
if children:
sym = dict(sym) # Create a copy to avoid mutating the original
sym["children"] = filter_symbols(children)
filtered.append(sym)
return filtered
return filter_symbols(list(symbols)) # type: ignore
+42 -4
View File
@@ -227,6 +227,9 @@ class SolidLanguageServer(ABC):
if solidlsp_settings is None:
solidlsp_settings = SolidLSPSettings()
# Ensure repository_root_path is absolute to avoid issues with file URIs
repository_root_path = os.path.abspath(repository_root_path)
ls_class = config.code_language.get_ls_class()
# For now, we assume that all language server implementations have the same signature of the constructor
# (which, unfortunately, differs from the signature of the base class).
@@ -463,6 +466,14 @@ class SolidLanguageServer(ABC):
def _start_server(self) -> None:
pass
def _get_language_id_for_file(self, relative_file_path: str) -> str:
"""Return the language ID for a file.
Override in subclasses to return file-specific language IDs.
Default implementation returns self.language_id.
"""
return self.language_id
@contextmanager
def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]:
"""
@@ -488,13 +499,14 @@ class SolidLanguageServer(ABC):
contents = FileUtils.read_file(absolute_file_path, self._encoding)
version = 0
self.open_file_buffers[uri] = LSPFileBuffer(uri, contents, version, self.language_id, 1)
language_id = self._get_language_id_for_file(relative_file_path)
self.open_file_buffers[uri] = LSPFileBuffer(uri, contents, version, language_id, 1)
self.server.notify.did_open_text_document(
{
LSPConstants.TEXT_DOCUMENT: { # type: ignore
LSPConstants.URI: uri,
LSPConstants.LANGUAGE_ID: self.language_id,
LSPConstants.LANGUAGE_ID: language_id,
LSPConstants.VERSION: 0,
LSPConstants.TEXT: contents,
}
@@ -1630,6 +1642,23 @@ class SolidLanguageServer(ABC):
include_body=include_body,
)
def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location:
"""
Select the preferred definition from a list of definitions.
When multiple definitions are returned (e.g., both source and type definitions),
this method determines which one to use. The base implementation simply returns
the first definition.
Subclasses can override this method to implement language-specific preferences.
For example, TypeScript/Vue servers may prefer source files over .d.ts type
definition files.
:param definitions: A non-empty list of definition locations.
:return: The preferred definition location.
"""
return definitions[0]
def request_defining_symbol(
self,
relative_file_path: str,
@@ -1658,8 +1687,8 @@ class SolidLanguageServer(ABC):
if not definitions:
return None
# Use the first definition location
definition = definitions[0]
# Select the preferred definition (subclasses can override _get_preferred_definition)
definition = self._get_preferred_definition(definitions)
def_path = definition["relativePath"]
assert def_path is not None
def_line = definition["range"]["start"]["line"]
@@ -1874,5 +1903,14 @@ class SolidLanguageServer(ABC):
def language_server(self) -> Self:
return self
@property
def handler(self) -> SolidLanguageServerHandler:
"""Access the underlying language server handler.
Useful for advanced operations like sending custom commands
or registering notification handlers.
"""
return self.server
def is_running(self) -> bool:
return self.server.is_running()
+43
View File
@@ -55,11 +55,14 @@ class Language(str, Enum):
NIX = "nix"
ERLANG = "erlang"
AL = "al"
FSHARP = "fsharp"
REGO = "rego"
SCALA = "scala"
JULIA = "julia"
FORTRAN = "fortran"
HASKELL = "haskell"
VUE = "vue"
POWERSHELL = "powershell"
# 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"""
@@ -109,6 +112,23 @@ class Language(str, Enum):
def __str__(self) -> str:
return self.value
def get_priority(self) -> int:
"""
:return: priority of the language for breaking ties between languages; higher is more important.
"""
# experimental languages have the lowest priority
if self.is_experimental():
return 0
# We assign lower priority to languages that are supersets of others, such that
# the "larger" language is only chosen when it matches more strongly
match self:
# languages that are supersets of others (Vue is superset of TypeScript/JavaScript)
case self.VUE:
return 1
# regular languages
case _:
return 2
def get_source_fn_matcher(self) -> FilenameMatcher:
match self:
case self.PYTHON | self.PYTHON_JEDI:
@@ -171,6 +191,8 @@ class Language(str, Enum):
return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src")
case self.AL:
return FilenameMatcher("*.al", "*.dal")
case self.FSHARP:
return FilenameMatcher("*.fs", "*.fsx", "*.fsi")
case self.REGO:
return FilenameMatcher("*.rego")
case self.MARKDOWN:
@@ -185,6 +207,15 @@ class Language(str, Enum):
)
case self.HASKELL:
return FilenameMatcher("*.hs", "*.lhs")
case self.VUE:
path_patterns = ["*.vue"]
for prefix in ["c", "m", ""]:
for postfix in ["x", ""]:
for base_pattern in ["ts", "js"]:
path_patterns.append(f"*.{prefix}{base_pattern}{postfix}")
return FilenameMatcher(*path_patterns)
case self.POWERSHELL:
return FilenameMatcher("*.ps1", "*.psm1", "*.psd1")
case _:
raise ValueError(f"Unhandled language: {self}")
@@ -226,6 +257,10 @@ class Language(str, Enum):
from solidlsp.language_servers.vts_language_server import VtsLanguageServer
return VtsLanguageServer
case self.VUE:
from solidlsp.language_servers.vue_language_server import VueLanguageServer
return VueLanguageServer
case self.GO:
from solidlsp.language_servers.gopls import Gopls
@@ -334,6 +369,14 @@ class Language(str, Enum):
from solidlsp.language_servers.haskell_language_server import HaskellLanguageServer
return HaskellLanguageServer
case self.FSHARP:
from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer
return FSharpLanguageServer
case self.POWERSHELL:
from solidlsp.language_servers.powershell_language_server import PowerShellLanguageServer
return PowerShellLanguageServer
case _:
raise ValueError(f"Unhandled language: {self}")
+94 -22
View File
@@ -1,17 +1,25 @@
import logging
import os
from collections.abc import Iterator
from pathlib import Path
import pytest
from blib2to3.pgen2.parse import contextmanager
from sensai.util.logging import configure
from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME
from serena.config.serena_config import SerenaPaths
from serena.constants import SERENA_MANAGED_DIR_NAME
from serena.project import Project
from serena.util.file_system import GitignoreParser
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import Language, LanguageServerConfig
from solidlsp.settings import SolidLSPSettings
configure(level=logging.ERROR)
from .solidlsp.clojure import is_clojure_cli_available
configure(level=logging.INFO)
log = logging.getLogger(__name__)
@pytest.fixture(scope="session")
@@ -29,12 +37,8 @@ def get_repo_path(language: Language) -> Path:
return Path(__file__).parent / "resources" / "repos" / language / "test_repo"
def create_ls(
language: Language,
repo_path: str | None = None,
ignored_paths: list[str] | None = None,
trace_lsp_communication: bool = False,
log_level: int = logging.ERROR,
def _create_ls(
language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False
) -> SolidLanguageServer:
ignored_paths = ignored_paths or []
if repo_path is None:
@@ -46,16 +50,43 @@ def create_ls(
return SolidLanguageServer.create(
config,
repo_path,
solidlsp_settings=SolidLSPSettings(solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, project_data_relative_path=SERENA_MANAGED_DIR_NAME),
solidlsp_settings=SolidLSPSettings(
solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_relative_path=SERENA_MANAGED_DIR_NAME
),
)
def create_default_ls(language: Language) -> SolidLanguageServer:
repo_path = str(get_repo_path(language))
return create_ls(language, repo_path)
@contextmanager
def start_ls_context(
language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False
) -> Iterator[SolidLanguageServer]:
ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication)
log.info(f"Starting language server for {language} {repo_path}")
ls.start()
try:
log.info(f"Language server started for {language} {repo_path}")
yield ls
finally:
log.info(f"Stopping language server for {language} {repo_path}")
try:
ls.stop(shutdown_timeout=5)
except Exception as e:
log.warning(f"Warning: Error stopping language server: {e}")
# try to force cleanup
if hasattr(ls, "server") and hasattr(ls.server, "process"):
try:
ls.server.process.terminate()
except:
pass
def create_default_project(language: Language) -> Project:
@contextmanager
def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer]:
with start_ls_context(language) as ls:
yield ls
def _create_default_project(language: Language) -> Project:
repo_path = str(get_repo_path(language))
return Project.load(repo_path)
@@ -81,7 +112,8 @@ def repo_path(request: LanguageParamRequest) -> Path:
return get_repo_path(language)
@pytest.fixture(scope="session")
# Note: using module scope here to avoid restarting LS for each test function but still terminate between test modules
@pytest.fixture(scope="module")
def language_server(request: LanguageParamRequest):
"""Create a language server instance configured for the specified language.
@@ -108,15 +140,11 @@ def language_server(request: LanguageParamRequest):
raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
language = request.param
server = create_default_ls(language)
server.start()
try:
yield server
finally:
server.stop()
with start_default_ls_context(language) as ls:
yield ls
@pytest.fixture(scope="session")
@pytest.fixture(scope="module")
def project(request: LanguageParamRequest):
"""Create a Project for the specified language.
@@ -143,4 +171,48 @@ def project(request: LanguageParamRequest):
raise ValueError("Language parameter must be provided via pytest.mark.parametrize")
language = request.param
yield create_default_project(language)
project = _create_default_project(language)
yield project
project.shutdown(timeout=5)
is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
"""
Flag indicating whether the tests are running in the GitHub CI environment.
"""
def _determine_disabled_languages() -> list[Language]:
"""
Determine which language tests should be disabled (based on the environment)
:return: the list of disabled languages
"""
result: list[Language] = []
java_tests_enabled = True
if not java_tests_enabled:
result.append(Language.JAVA)
clojure_tests_enabled = is_clojure_cli_available()
if not clojure_tests_enabled:
result.append(Language.CLOJURE)
al_tests_enabled = True
if not al_tests_enabled:
result.append(Language.AL)
return result
_disabled_languages = _determine_disabled_languages()
def language_tests_enabled(language: Language) -> bool:
"""
Check if tests for the given language are enabled in the current environment.
:param language: the language to check
:return: True if tests for the language are enabled, False otherwise
"""
return language not in _disabled_languages
@@ -0,0 +1,5 @@
bin/
obj/
*.user
.vscode/
.ionide/
@@ -0,0 +1,32 @@
module Calculator
/// Simple calculator functions
let add a b = a + b
let subtract a b = a - b
let multiply a b = a * b
let divide a b =
if b = 0 then
failwith "Cannot divide by zero"
else
(float a) / (float b)
/// More complex operations
let square x = x * x
let factorial n =
if n <= 0 then 1
else
let rec factorialHelper acc n =
if n <= 1 then acc
else factorialHelper (acc * n) (n - 1)
factorialHelper 1 n
/// Calculator type with instance methods
type CalculatorClass() =
member this.Add(a, b) = add a b
member this.Subtract(a, b) = subtract a b
member this.Multiply(a, b) = multiply a b
member this.Divide(a, b) = divide a b
@@ -0,0 +1,44 @@
namespace Models
/// Person record type
type Person = {
Name: string
Age: int
Email: string option
}
module PersonModule =
/// Create a new person
let createPerson name age email =
{ Name = name; Age = age; Email = email }
/// Check if person is an adult
let isAdult person =
person.Age >= 18
/// Get display name
let getDisplayName person =
match person.Email with
| Some email -> $"{person.Name} ({email})"
| None -> person.Name
/// Update person age
let updateAge newAge person =
{ person with Age = newAge }
/// Address type
type Address = {
Street: string
City: string
ZipCode: string
Country: string
}
/// Employee type that extends Person concept
type Employee = {
Person: Person
EmployeeId: int
Department: string
Salary: decimal
Address: Address option
}
@@ -0,0 +1,37 @@
module Program
open Calculator
open Models
[<EntryPoint>]
let main argv =
printfn "Hello, F# World!"
// Test calculator functions
let result1 = add 5 3
printfn "5 + 3 = %d" result1
let result2 = subtract 10 4
printfn "10 - 4 = %d" result2
let result3 = multiply 6 7
printfn "6 * 7 = %d" result3
let result4 = divide 15 3
printfn "15 / 3 = %.2f" result4
// Test calculator class
let calc = CalculatorClass()
let classResult = calc.Add(20, 5)
printfn "Calculator class: 20 + 5 = %d" classResult
// Test person module
let person = PersonModule.createPerson "Alice Smith" 25 (Some "alice@example.com")
printfn "Person: %s" (PersonModule.getDisplayName person)
printfn "Is adult: %b" (PersonModule.isAdult person)
// Test factorial
let fact5 = factorial 5
printfn "5! = %d" fact5
0 // return success
@@ -0,0 +1,21 @@
# F# Test Project
This is a test F# project for testing Serena's F# language support.
## Project Structure
- `Program.fs` - Main program entry point
- `Calculator.fs` - Calculator functions and types
- `Models/Person.fs` - Person and Employee data models
## Build
```bash
dotnet build
```
## Run
```bash
dotnet run
```
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Calculator.fs" />
<Compile Include="Models/Person.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
@@ -10,4 +10,12 @@ public class Model {
public String getName() {
return name;
}
public String getName(int maxChars) {
if (name.length() <= maxChars) {
return name;
} else {
return name.substring(0, maxChars) + "...";
}
}
}
@@ -0,0 +1 @@
{"status":"started","languageServiceTransport":"Stdio","powerShellVersion":"7.5.3"}
@@ -0,0 +1,109 @@
# Main script demonstrating various PowerShell features
# Import utility functions
. "$PSScriptRoot\utils.ps1"
# Global variables
$Script:ScriptName = "Main Script"
$Script:Counter = 0
<#
.SYNOPSIS
Greets a user with various greeting styles.
.PARAMETER Username
The name of the user to greet.
.PARAMETER GreetingType
The type of greeting (formal, casual, or default).
#>
function Greet-User {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Username,
[Parameter(Mandatory = $false)]
[ValidateSet("formal", "casual", "default")]
[string]$GreetingType = "default"
)
switch ($GreetingType) {
"formal" {
Write-Output "Good day, $Username!"
}
"casual" {
Write-Output "Hey $Username!"
}
default {
Write-Output "Hello, $Username!"
}
}
}
<#
.SYNOPSIS
Processes an array of items with the specified operation.
.PARAMETER Items
The array of items to process.
.PARAMETER Operation
The operation to perform (count, uppercase).
#>
function Process-Items {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Items,
[Parameter(Mandatory = $true)]
[ValidateSet("count", "uppercase")]
[string]$Operation
)
foreach ($item in $Items) {
switch ($Operation) {
"count" {
$Script:Counter++
Write-Output "Processing item $($Script:Counter): $item"
}
"uppercase" {
Write-Output $item.ToUpper()
}
}
}
}
<#
.SYNOPSIS
Main entry point for the script.
#>
function Main {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$User = "World",
[Parameter(Mandatory = $false)]
[string]$Greeting = "default"
)
Write-Output "Starting $Script:ScriptName"
# Use the Greet-User function
Greet-User -Username $User -GreetingType $Greeting
# Process some items
$items = @("item1", "item2", "item3")
Write-Output "Processing items..."
Process-Items -Items $items -Operation "count"
# Use utility functions from utils.ps1
$upperName = Convert-ToUpperCase -InputString $User
Write-Output "Uppercase name: $upperName"
$trimmed = Remove-Whitespace -InputString " Hello World "
Write-Output "Trimmed: '$trimmed'"
Write-Output "Script completed successfully"
}
# Run main function
Main @args
@@ -0,0 +1,185 @@
# Utility functions for PowerShell operations
<#
.SYNOPSIS
Converts a string to uppercase.
.PARAMETER InputString
The string to convert.
.OUTPUTS
System.String - The uppercase string.
#>
function Convert-ToUpperCase {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[string]$InputString
)
return $InputString.ToUpper()
}
<#
.SYNOPSIS
Converts a string to lowercase.
.PARAMETER InputString
The string to convert.
.OUTPUTS
System.String - The lowercase string.
#>
function Convert-ToLowerCase {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[string]$InputString
)
return $InputString.ToLower()
}
<#
.SYNOPSIS
Removes leading and trailing whitespace from a string.
.PARAMETER InputString
The string to trim.
.OUTPUTS
System.String - The trimmed string.
#>
function Remove-Whitespace {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true, ValueFromPipeline = $true)]
[string]$InputString
)
return $InputString.Trim()
}
<#
.SYNOPSIS
Creates a backup of a file.
.PARAMETER FilePath
The path to the file to backup.
.PARAMETER BackupDirectory
The directory where the backup will be created.
.OUTPUTS
System.String - The path to the backup file.
#>
function Backup-File {
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $false)]
[string]$BackupDirectory = "."
)
if (-not (Test-Path $FilePath)) {
throw "File not found: $FilePath"
}
$fileName = Split-Path $FilePath -Leaf
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$backupName = "$fileName.$timestamp.bak"
$backupPath = Join-Path $BackupDirectory $backupName
Copy-Item -Path $FilePath -Destination $backupPath
return $backupPath
}
<#
.SYNOPSIS
Checks if an array contains a specific element.
.PARAMETER Array
The array to search.
.PARAMETER Element
The element to find.
.OUTPUTS
System.Boolean - True if the element is found, false otherwise.
#>
function Test-ArrayContains {
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)]
[array]$Array,
[Parameter(Mandatory = $true)]
$Element
)
return $Array -contains $Element
}
<#
.SYNOPSIS
Writes a log message with timestamp.
.PARAMETER Message
The message to log.
.PARAMETER Level
The log level (Info, Warning, Error).
#>
function Write-LogMessage {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Message,
[Parameter(Mandatory = $false)]
[ValidateSet("Info", "Warning", "Error")]
[string]$Level = "Info"
)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] [$Level] $Message"
switch ($Level) {
"Info" { Write-Host $logEntry -ForegroundColor White }
"Warning" { Write-Host $logEntry -ForegroundColor Yellow }
"Error" { Write-Host $logEntry -ForegroundColor Red }
}
}
<#
.SYNOPSIS
Validates if a string is a valid email address.
.PARAMETER Email
The email address to validate.
.OUTPUTS
System.Boolean - True if the email is valid, false otherwise.
#>
function Test-ValidEmail {
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)]
[string]$Email
)
$emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return $Email -match $emailRegex
}
<#
.SYNOPSIS
Checks if a string is a valid number.
.PARAMETER Value
The string to check.
.OUTPUTS
System.Boolean - True if the string is a valid number, false otherwise.
#>
function Test-IsNumber {
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)]
[string]$Value
)
$number = 0
return [double]::TryParse($Value, [ref]$number)
}
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue Calculator</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,14 @@
{
"name": "vue-calculator-test-fixture",
"version": "1.0.0",
"type": "module",
"description": "Vue 3 + Pinia + TypeScript test fixtures for Serena LSP testing",
"dependencies": {
"vue": "^3.4.0",
"pinia": "^2.1.0"
},
"devDependencies": {
"@vue/language-server": "^2.0.0",
"typescript": "~5.5.4"
}
}
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { ref, computed, watch, watchEffect, onMounted } from 'vue'
import { useCalculatorStore } from '@/stores/calculator'
import { useThemeProvider } from '@/composables/useTheme'
import { useTimeFormatter } from '@/composables/useFormatter'
import CalculatorInput from '@/components/CalculatorInput.vue'
import CalculatorDisplay from '@/components/CalculatorDisplay.vue'
// Get the calculator store
const store = useCalculatorStore()
// Use theme composable with provide/inject - provides theme to all child components
const themeManager = useThemeProvider()
// Use time formatter composable
const timeFormatter = useTimeFormatter()
// Local ref for app title
const appTitle = ref('Vue Calculator')
// Computed property for app version
const appVersion = computed((): string => {
return '1.0.0'
})
// Computed property for greeting message
const greetingMessage = computed((): string => {
const hour = new Date().getHours()
if (hour < 12) return 'Good morning'
if (hour < 18) return 'Good afternoon'
return 'Good evening'
})
// Get statistics from store
const totalCalculations = computed((): number => {
return store.history.length
})
// Check if calculator is active
const isCalculatorActive = computed((): boolean => {
return store.currentValue !== 0 || store.previousValue !== null
})
// Get last calculation time
const lastCalculationTime = computed((): string => {
if (store.history.length === 0) return 'No calculations yet'
const lastEntry = store.history[store.history.length - 1]
return timeFormatter.getRelativeTime(lastEntry.timestamp)
})
// Watch for calculation count changes - demonstrates watchEffect
watchEffect(() => {
document.title = `Calculator (${totalCalculations.value} calculations)`
})
// Watch for theme changes - demonstrates watch
watch(
() => themeManager.isDarkMode.value,
(isDark) => {
console.log(`Theme changed to ${isDark ? 'dark' : 'light'} mode`)
}
)
// Lifecycle hook
onMounted(() => {
console.log('App component mounted')
console.log(`Initial calculations: ${totalCalculations.value}`)
})
// Toggle theme method
const toggleTheme = () => {
themeManager.toggleDarkMode()
}
<template>
<div :class="['app', { 'dark-mode': themeManager.isDarkMode }]">
<header class="app-header">
<h1>{{ appTitle }}</h1>
<div class="header-info">
<span class="greeting">{{ greetingMessage }}!</span>
<span class="version">v{{ appVersion }}</span>
<button @click="toggleTheme" class="btn-theme">
{{ themeManager.isDarkMode ? '☀️' : '🌙' }}
</button>
</div>
</header>
<main class="app-main">
<div class="calculator-container">
<div class="stats-bar">
<span>Total Calculations: {{ totalCalculations }}</span>
<span :class="{ 'active-indicator': isCalculatorActive }">
{{ isCalculatorActive ? 'Active' : 'Idle' }}
</span>
<span class="last-calc-time">{{ lastCalculationTime }}</span>
</div>
<div class="calculator-grid">
<CalculatorInput />
<CalculatorDisplay />
</div>
</div>
</main>
<footer class="app-footer">
<p>Built with Vue 3 + Pinia + TypeScript</p>
</footer>
</div>
</template>
<style scoped>
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: background 0.3s ease;
}
.app.dark-mode {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
}
.app-header {
padding: 2rem;
color: white;
text-align: center;
}
.app-header h1 {
margin: 0 0 1rem 0;
font-size: 2.5rem;
}
.header-info {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
}
.greeting {
font-size: 1.1rem;
}
.version {
font-size: 0.9rem;
opacity: 0.8;
}
.btn-theme {
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
font-size: 1.2rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-theme:hover {
background: rgba(255, 255, 255, 0.3);
}
.app-main {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.calculator-container {
width: 100%;
max-width: 1200px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.stats-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.9);
border-radius: 8px;
font-weight: 500;
}
.last-calc-time {
font-size: 0.9rem;
color: #666;
font-style: italic;
}
.active-indicator {
color: #4caf50;
font-weight: bold;
}
.active-indicator:not(.active-indicator) {
color: #999;
}
.calculator-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
@media (max-width: 768px) {
.calculator-grid {
grid-template-columns: 1fr;
}
}
.app-footer {
padding: 1rem;
text-align: center;
color: white;
opacity: 0.8;
}
.app-footer p {
margin: 0;
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
/**
* Props interface for CalculatorButton.
* Demonstrates: defineProps with TypeScript interface
*/
interface Props {
label: string | number
variant?: 'digit' | 'operation' | 'equals' | 'clear'
disabled?: boolean
active?: boolean
size?: 'small' | 'medium' | 'large'
}
/**
* Emits interface for CalculatorButton.
* Demonstrates: defineEmits with TypeScript
*/
interface Emits {
click: [value: string | number]
hover: [isHovering: boolean]
focus: []
blur: []
}
// Define props with defaults
const props = withDefaults(defineProps<Props>(), {
variant: 'digit',
disabled: false,
active: false,
size: 'medium'
})
// Define emits
const emit = defineEmits<Emits>()
// Local state
const isHovered = ref(false)
const isFocused = ref(false)
const pressCount = ref(0)
// Computed classes based on props and state
const buttonClass = computed(() => {
const classes = ['calc-button', `calc-button--${props.variant}`, `calc-button--${props.size}`]
if (props.active) classes.push('calc-button--active')
if (props.disabled) classes.push('calc-button--disabled')
if (isHovered.value) classes.push('calc-button--hovered')
if (isFocused.value) classes.push('calc-button--focused')
return classes.join(' ')
})
// Computed aria label for accessibility
const ariaLabel = computed(() => {
const variantText = {
digit: 'Number',
operation: 'Operation',
equals: 'Equals',
clear: 'Clear'
}[props.variant]
return `${variantText}: ${props.label}`
})
// Event handlers that emit events
const handleClick = () => {
if (!props.disabled) {
pressCount.value++
emit('click', props.label)
}
}
const handleMouseEnter = () => {
isHovered.value = true
emit('hover', true)
}
const handleMouseLeave = () => {
isHovered.value = false
emit('hover', false)
}
const handleFocus = () => {
isFocused.value = true
emit('focus')
}
const handleBlur = () => {
isFocused.value = false
emit('blur')
}
// Expose internal state for parent access via template refs
// Demonstrates: defineExpose
defineExpose({
pressCount,
isHovered,
isFocused,
simulateClick: handleClick
})
</script>
<template>
<button
:class="buttonClass"
:disabled="disabled"
:aria-label="ariaLabel"
@click="handleClick"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@focus="handleFocus"
@blur="handleBlur"
>
<span class="calc-button__label">{{ label }}</span>
<span v-if="pressCount > 0" class="calc-button__badge">{{ pressCount }}</span>
</button>
</template>
<style scoped>
.calc-button {
position: relative;
padding: 1rem;
font-size: 1.2rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.calc-button--small {
padding: 0.5rem;
font-size: 1rem;
}
.calc-button--medium {
padding: 1rem;
font-size: 1.2rem;
}
.calc-button--large {
padding: 1.5rem;
font-size: 1.5rem;
}
.calc-button--digit {
background: white;
color: #333;
}
.calc-button--digit:hover:not(:disabled) {
background: #e0e0e0;
}
.calc-button--operation {
background: #2196f3;
color: white;
}
.calc-button--operation:hover:not(:disabled) {
background: #1976d2;
}
.calc-button--operation.calc-button--active {
background: #1565c0;
}
.calc-button--equals {
background: #4caf50;
color: white;
}
.calc-button--equals:hover:not(:disabled) {
background: #45a049;
}
.calc-button--clear {
background: #f44336;
color: white;
}
.calc-button--clear:hover:not(:disabled) {
background: #da190b;
}
.calc-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.calc-button--hovered {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.calc-button--focused {
outline: 2px solid #2196f3;
outline-offset: 2px;
}
.calc-button__label {
display: block;
}
.calc-button__badge {
position: absolute;
top: -5px;
right: -5px;
background: #ff5722;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 0.7rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useCalculatorStore } from '@/stores/calculator'
import type { HistoryEntry } from '@/types'
// Get the calculator store
const store = useCalculatorStore()
// Use storeToRefs to get reactive references to store state
const { recentHistory, hasHistory, currentValue, operation } = storeToRefs(store)
// Local ref for display options
const showFullHistory = ref(false)
const maxHistoryItems = ref(5)
// Computed property for filtered history
const displayedHistory = computed((): HistoryEntry[] => {
if (showFullHistory.value) {
return recentHistory.value
}
return recentHistory.value.slice(0, maxHistoryItems.value)
})
// Format date for display
const formatDate = (date: Date): string => {
return new Date(date).toLocaleTimeString()
}
// Format the current calculation status
const currentCalculation = computed((): string => {
if (operation.value && store.previousValue !== null) {
const opSymbol = {
add: '+',
subtract: '-',
multiply: '×',
divide: '÷'
}[operation.value]
return `${store.previousValue} ${opSymbol} ${currentValue.value}`
}
return currentValue.value.toString()
})
// Toggle history view
const toggleHistoryView = () => {
showFullHistory.value = !showFullHistory.value
}
// Clear history handler
const clearHistory = () => {
store.clearHistory()
}
// Check if history is empty
const isHistoryEmpty = computed((): boolean => {
return !hasHistory.value
})
</script>
<template>
<div class="calculator-display">
<div class="current-calculation">
<h3>Current Calculation</h3>
<div class="calculation-value">
{{ currentCalculation }}
</div>
</div>
<div class="history-section">
<div class="history-header">
<h3>History</h3>
<button
v-if="hasHistory"
@click="toggleHistoryView"
class="btn-toggle"
>
{{ showFullHistory ? 'Show Less' : 'Show All' }}
</button>
<button
v-if="hasHistory"
@click="clearHistory"
class="btn-clear-history"
>
Clear History
</button>
</div>
<div v-if="isHistoryEmpty" class="empty-history">
No calculations yet
</div>
<div v-else class="history-list">
<div
v-for="(entry, index) in displayedHistory"
:key="`${entry.timestamp}-${index}`"
class="history-item"
>
<span class="expression">{{ entry.expression }}</span>
<span class="result">= {{ entry.result }}</span>
<span class="timestamp">{{ formatDate(entry.timestamp) }}</span>
</div>
</div>
<div v-if="!showFullHistory && recentHistory.length > maxHistoryItems" class="history-count">
Showing {{ maxHistoryItems }} of {{ recentHistory.length }} entries
</div>
</div>
</div>
</template>
<style scoped>
.calculator-display {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.current-calculation {
padding: 1rem;
background: #e3f2fd;
border-radius: 4px;
}
.current-calculation h3 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: #1976d2;
}
.calculation-value {
font-size: 1.5rem;
font-weight: bold;
color: #333;
}
.history-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.history-header {
display: flex;
align-items: center;
gap: 1rem;
}
.history-header h3 {
margin: 0;
flex: 1;
}
.btn-toggle,
.btn-clear-history {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-toggle {
background: #2196f3;
color: white;
}
.btn-toggle:hover {
background: #1976d2;
}
.btn-clear-history {
background: #f44336;
color: white;
}
.btn-clear-history:hover {
background: #da190b;
}
.empty-history {
padding: 2rem;
text-align: center;
color: #999;
font-style: italic;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.history-item {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 1rem;
padding: 0.75rem;
background: #f5f5f5;
border-radius: 4px;
align-items: center;
}
.expression {
font-weight: 500;
}
.result {
font-weight: bold;
color: #4caf50;
}
.timestamp {
font-size: 0.8rem;
color: #666;
text-align: right;
}
.history-count {
text-align: center;
font-size: 0.9rem;
color: #666;
padding: 0.5rem;
}
</style>
@@ -0,0 +1,300 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useCalculatorStore } from '@/stores/calculator'
import { useFormatter } from '@/composables/useFormatter'
import CalculatorButton from './CalculatorButton.vue'
import type { Operation } from '@/types'
// Get the calculator store
const store = useCalculatorStore()
// Use composable for formatting
const formatter = useFormatter(2)
// Local refs for component state
const isOperationPending = ref(false)
const lastOperation = ref<Operation>(null)
const keyboardEnabled = ref(true)
const operationHistory = ref<string[]>([])
// Template refs - demonstrates template ref pattern
const displayRef = ref<HTMLDivElement | null>(null)
const equalsButtonRef = ref<InstanceType<typeof CalculatorButton> | null>(null)
// Computed property for button styling
const getOperationClass = computed(() => (op: Operation) => {
return lastOperation.value === op ? 'active' : ''
})
// Computed formatted display value using composable
const formattedDisplay = computed(() => {
const value = parseFloat(store.display)
return isNaN(value) ? store.display : formatter.formatNumber(value)
})
// Watch for operation changes - demonstrates watch
watch(lastOperation, (newOp, oldOp) => {
if (newOp !== oldOp && newOp) {
operationHistory.value.push(newOp)
// Keep only last 10 operations
if (operationHistory.value.length > 10) {
operationHistory.value.shift()
}
}
})
// Watch store display changes - demonstrates watch with callback
watch(
() => store.display,
(newDisplay) => {
if (displayRef.value) {
// Trigger animation on display change
displayRef.value.classList.add('display-updated')
setTimeout(() => {
displayRef.value?.classList.remove('display-updated')
}, 300)
}
}
)
// Lifecycle hook - demonstrates onMounted
onMounted(() => {
console.log('CalculatorInput mounted')
// Add keyboard event listener
window.addEventListener('keydown', handleKeyboard)
// Focus on the display element
if (displayRef.value) {
displayRef.value.focus()
}
})
// Lifecycle hook - demonstrates onBeforeUnmount
onBeforeUnmount(() => {
console.log('CalculatorInput unmounting')
// Clean up keyboard event listener
window.removeEventListener('keydown', handleKeyboard)
})
// Handle number button clicks
const handleDigit = (digit: number) => {
store.appendDigit(digit)
isOperationPending.value = false
}
// Handle operation button clicks
const handleOperation = (operation: Operation) => {
isOperationPending.value = true
lastOperation.value = operation
switch (operation) {
case 'add':
store.add()
break
case 'subtract':
store.subtract()
break
case 'multiply':
store.multiply()
break
case 'divide':
store.divide()
break
}
}
// Handle equals button
const handleEquals = () => {
store.equals()
isOperationPending.value = false
lastOperation.value = null
// Access exposed method from child component
if (equalsButtonRef.value) {
console.log('Equals button press count:', equalsButtonRef.value.pressCount)
}
}
// Handle clear button
const handleClear = () => {
store.clear()
isOperationPending.value = false
lastOperation.value = null
operationHistory.value = []
}
// Keyboard handler - demonstrates event handling
const handleKeyboard = (event: KeyboardEvent) => {
if (!keyboardEnabled.value) return
const key = event.key
if (key >= '0' && key <= '9') {
handleDigit(parseInt(key))
} else if (key === '+') {
handleOperation('add')
} else if (key === '-') {
handleOperation('subtract')
} else if (key === '*') {
handleOperation('multiply')
} else if (key === '/') {
event.preventDefault()
handleOperation('divide')
} else if (key === 'Enter' || key === '=') {
handleEquals()
} else if (key === 'Escape' || key === 'c' || key === 'C') {
handleClear()
}
}
// Toggle keyboard input
const toggleKeyboard = () => {
keyboardEnabled.value = !keyboardEnabled.value
}
// Array of digits for rendering
const digits = [7, 8, 9, 4, 5, 6, 1, 2, 3, 0]
</script>
<template>
<div class="calculator-input">
<div ref="displayRef" class="display" tabindex="0">
{{ formattedDisplay }}
</div>
<div class="keyboard-toggle">
<label>
<input type="checkbox" v-model="keyboardEnabled" @change="toggleKeyboard" />
Enable Keyboard Input
</label>
</div>
<div class="buttons">
<CalculatorButton
v-for="digit in digits"
:key="digit"
:label="digit"
variant="digit"
@click="handleDigit"
/>
<CalculatorButton
label="+"
variant="operation"
:active="lastOperation === 'add'"
@click="() => handleOperation('add')"
/>
<CalculatorButton
label="-"
variant="operation"
:active="lastOperation === 'subtract'"
@click="() => handleOperation('subtract')"
/>
<CalculatorButton
label="×"
variant="operation"
:active="lastOperation === 'multiply'"
@click="() => handleOperation('multiply')"
/>
<CalculatorButton
label="÷"
variant="operation"
:active="lastOperation === 'divide'"
@click="() => handleOperation('divide')"
/>
<CalculatorButton
ref="equalsButtonRef"
label="="
variant="equals"
size="large"
@click="handleEquals"
/>
<CalculatorButton
label="C"
variant="clear"
@click="handleClear"
/>
</div>
<div v-if="isOperationPending" class="pending-indicator">
Operation pending: {{ lastOperation }}
</div>
<div v-if="operationHistory.length > 0" class="operation-history">
Recent operations: {{ operationHistory.join(', ') }}
</div>
</div>
</template>
<style scoped>
.calculator-input {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 8px;
}
.display {
font-size: 2rem;
text-align: right;
padding: 1rem;
background: white;
border-radius: 4px;
min-height: 3rem;
transition: background-color 0.3s;
outline: none;
}
.display:focus {
box-shadow: 0 0 0 2px #2196f3;
}
.display.display-updated {
background-color: #e3f2fd;
}
.keyboard-toggle {
display: flex;
justify-content: center;
padding: 0.5rem;
}
.keyboard-toggle label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.pending-indicator {
font-size: 0.9rem;
color: #666;
text-align: center;
font-style: italic;
}
.operation-history {
font-size: 0.8rem;
color: #999;
text-align: center;
padding: 0.5rem;
background: white;
border-radius: 4px;
max-height: 3rem;
overflow: auto;
}
</style>
@@ -0,0 +1,126 @@
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import type { FormatOptions } from '@/types'
/**
* Composable for formatting numbers with various options.
* Demonstrates: composable pattern, refs, computed, type imports
*/
export function useFormatter(initialPrecision: number = 2) {
// State
const precision = ref<number>(initialPrecision)
const useGrouping = ref<boolean>(true)
const locale = ref<string>('en-US')
// Computed properties
const formatOptions = computed((): FormatOptions => ({
maxDecimals: precision.value,
useGrouping: useGrouping.value
}))
// Methods
const formatNumber = (value: number): string => {
return value.toLocaleString(locale.value, {
minimumFractionDigits: precision.value,
maximumFractionDigits: precision.value,
useGrouping: useGrouping.value
})
}
const formatCurrency = (value: number, currency: string = 'USD'): string => {
return value.toLocaleString(locale.value, {
style: 'currency',
currency,
minimumFractionDigits: precision.value,
maximumFractionDigits: precision.value
})
}
const formatPercentage = (value: number): string => {
return `${(value * 100).toFixed(precision.value)}%`
}
const setPrecision = (newPrecision: number): void => {
if (newPrecision >= 0 && newPrecision <= 10) {
precision.value = newPrecision
}
}
const toggleGrouping = (): void => {
useGrouping.value = !useGrouping.value
}
const setLocale = (newLocale: string): void => {
locale.value = newLocale
}
// Return composable API
return {
// State (readonly)
precision: computed(() => precision.value),
useGrouping: computed(() => useGrouping.value),
locale: computed(() => locale.value),
formatOptions,
// Methods
formatNumber,
formatCurrency,
formatPercentage,
setPrecision,
toggleGrouping,
setLocale
}
}
/**
* Composable for time formatting.
* Demonstrates: simpler composable, pure functions
*/
export function useTimeFormatter() {
const formatTime = (date: Date): string => {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const formatDate = (date: Date): string => {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const formatDateTime = (date: Date): string => {
return `${formatDate(date)} ${formatTime(date)}`
}
const getRelativeTime = (date: Date): string => {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSecs = Math.floor(diffMs / 1000)
const diffMins = Math.floor(diffSecs / 60)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffSecs < 60) return 'just now'
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`
}
return {
formatTime,
formatDate,
formatDateTime,
getRelativeTime
}
}
/**
* Type definitions for return types
*/
export type UseFormatterReturn = ReturnType<typeof useFormatter>
export type UseTimeFormatterReturn = ReturnType<typeof useTimeFormatter>
@@ -0,0 +1,113 @@
import { ref, computed, watch, inject, provide, type InjectionKey, type Ref } from 'vue'
/**
* Theme configuration type
*/
export interface ThemeConfig {
isDark: boolean
primaryColor: string
fontSize: number
}
/**
* Injection key for theme - demonstrates provide/inject pattern
*/
export const ThemeKey: InjectionKey<Ref<ThemeConfig>> = Symbol('theme')
/**
* Composable for theme management with watchers.
* Demonstrates: watch, provide/inject, localStorage interaction
*/
export function useThemeProvider() {
// Initialize theme from localStorage or defaults
const loadThemeFromStorage = (): ThemeConfig => {
const stored = localStorage.getItem('app-theme')
if (stored) {
try {
return JSON.parse(stored)
} catch {
// Fall through to defaults
}
}
return {
isDark: false,
primaryColor: '#667eea',
fontSize: 16
}
}
const theme = ref<ThemeConfig>(loadThemeFromStorage())
// Computed properties
const isDarkMode = computed(() => theme.value.isDark)
const themeClass = computed(() => theme.value.isDark ? 'dark-theme' : 'light-theme')
// Watch for theme changes and persist to localStorage
watch(
theme,
(newTheme) => {
localStorage.setItem('app-theme', JSON.stringify(newTheme))
document.documentElement.className = newTheme.isDark ? 'dark' : 'light'
},
{ deep: true }
)
// Methods
const toggleDarkMode = (): void => {
theme.value.isDark = !theme.value.isDark
}
const setPrimaryColor = (color: string): void => {
theme.value.primaryColor = color
}
const setFontSize = (size: number): void => {
if (size >= 12 && size <= 24) {
theme.value.fontSize = size
}
}
const resetTheme = (): void => {
theme.value = {
isDark: false,
primaryColor: '#667eea',
fontSize: 16
}
}
// Provide theme to child components
provide(ThemeKey, theme)
return {
theme,
isDarkMode,
themeClass,
toggleDarkMode,
setPrimaryColor,
setFontSize,
resetTheme
}
}
/**
* Composable for consuming theme in child components.
* Demonstrates: inject pattern
*/
export function useTheme() {
const theme = inject(ThemeKey)
if (!theme) {
throw new Error('useTheme must be used within a component that provides ThemeKey')
}
const isDark = computed(() => theme.value.isDark)
const primaryColor = computed(() => theme.value.primaryColor)
const fontSize = computed(() => theme.value.fontSize)
return {
theme,
isDark,
primaryColor,
fontSize
}
}
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
@@ -0,0 +1,179 @@
import { defineStore } from 'pinia'
import type { HistoryEntry, Operation, CalculatorState } from '@/types'
export const useCalculatorStore = defineStore('calculator', {
state: (): CalculatorState => ({
currentValue: 0,
previousValue: null,
operation: null,
history: [],
displayValue: '0'
}),
getters: {
/**
* Get the most recent history entries (last 10)
*/
recentHistory: (state): HistoryEntry[] => {
return state.history.slice(-10).reverse()
},
/**
* Check if calculator has any history
*/
hasHistory: (state): boolean => {
return state.history.length > 0
},
/**
* Get the current display text
*/
display: (state): string => {
return state.displayValue
}
},
actions: {
/**
* Set a number value
*/
setNumber(value: number) {
this.currentValue = value
this.displayValue = value.toString()
},
/**
* Append a digit to the current value
*/
appendDigit(digit: number) {
if (this.displayValue === '0') {
this.displayValue = digit.toString()
} else {
this.displayValue += digit.toString()
}
this.currentValue = parseFloat(this.displayValue)
},
/**
* Add two numbers
*/
add() {
if (this.previousValue !== null && this.operation) {
this.executeOperation()
}
this.previousValue = this.currentValue
this.operation = 'add'
this.displayValue = '0'
},
/**
* Subtract two numbers
*/
subtract() {
if (this.previousValue !== null && this.operation) {
this.executeOperation()
}
this.previousValue = this.currentValue
this.operation = 'subtract'
this.displayValue = '0'
},
/**
* Multiply two numbers
*/
multiply() {
if (this.previousValue !== null && this.operation) {
this.executeOperation()
}
this.previousValue = this.currentValue
this.operation = 'multiply'
this.displayValue = '0'
},
/**
* Divide two numbers
*/
divide() {
if (this.previousValue !== null && this.operation) {
this.executeOperation()
}
this.previousValue = this.currentValue
this.operation = 'divide'
this.displayValue = '0'
},
/**
* Execute the pending operation
*/
executeOperation() {
if (this.previousValue === null || this.operation === null) {
return
}
let result = 0
const prev = this.previousValue
const current = this.currentValue
let expression = ''
switch (this.operation) {
case 'add':
result = prev + current
expression = `${prev} + ${current}`
break
case 'subtract':
result = prev - current
expression = `${prev} - ${current}`
break
case 'multiply':
result = prev * current
expression = `${prev} × ${current}`
break
case 'divide':
if (current === 0) {
this.displayValue = 'Error'
this.clear()
return
}
result = prev / current
expression = `${prev} ÷ ${current}`
break
}
// Add to history
this.history.push({
expression,
result,
timestamp: new Date()
})
this.currentValue = result
this.displayValue = result.toString()
this.previousValue = null
this.operation = null
},
/**
* Calculate the equals operation
*/
equals() {
this.executeOperation()
},
/**
* Clear the calculator state
*/
clear() {
this.currentValue = 0
this.previousValue = null
this.operation = null
this.displayValue = '0'
},
/**
* Clear all history
*/
clearHistory() {
this.history = []
}
}
})
@@ -0,0 +1,32 @@
/**
* Represents a single calculation in the history
*/
export interface HistoryEntry {
expression: string
result: number
timestamp: Date
}
/**
* Valid calculator operations
*/
export type Operation = 'add' | 'subtract' | 'multiply' | 'divide' | null
/**
* The complete state of the calculator
*/
export interface CalculatorState {
currentValue: number
previousValue: number | null
operation: Operation
history: HistoryEntry[]
displayValue: string
}
/**
* Format options for displaying numbers
*/
export interface FormatOptions {
maxDecimals?: number
useGrouping?: boolean
}
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"plugins": [
{
"name": "@vue/typescript-plugin"
}
],
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
File diff suppressed because it is too large. Load diff
+10
View File
@@ -43,6 +43,16 @@ class TestProjectConfigAutogenerate:
assert config.project_name == self.project_path.name
assert config.languages == [Language.PYTHON]
def test_autogenerate_with_js_files(self):
"""Test successful autogeneration with JavaScript source files."""
# Create files for multiple languages
(self.project_path / "small.js").write_text("console.log('JS');")
# Run autogenerate - should pick Python as dominant
config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False)
assert config.languages == [Language.TYPESCRIPT]
def test_autogenerate_with_multiple_languages(self):
"""Test autogeneration picks dominant language when multiple are present."""
# Create files for multiple languages
+84 -13
View File
@@ -2,16 +2,17 @@ import json
import logging
import os
import time
from collections.abc import Iterator
import pytest
import test.solidlsp.clojure as clj
from serena.agent import SerenaAgent
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.project import Project
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, ReplaceSymbolBodyTool
from solidlsp.ls_config import Language
from test.conftest import get_repo_path
from test.conftest import get_repo_path, language_tests_enabled
from test.solidlsp import clojure as clj
@pytest.fixture
@@ -29,6 +30,8 @@ def serena_config():
Language.PHP,
Language.CSHARP,
Language.CLOJURE,
Language.FSHARP,
Language.POWERSHELL,
]:
repo_path = get_repo_path(language)
if repo_path.exists():
@@ -54,11 +57,21 @@ def serena_config():
@pytest.fixture
def serena_agent(request: pytest.FixtureRequest, serena_config):
def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[SerenaAgent]:
language = Language(request.param)
if not language_tests_enabled(language):
pytest.skip(f"Tests for language {language} are not enabled.")
project_name = f"test_repo_{language}"
return SerenaAgent(project=project_name, serena_config=serena_config)
agent = SerenaAgent(project=project_name, serena_config=serena_config)
# wait for agent to be ready
agent.execute_task(lambda: None)
yield agent
# explicitly delete to free resources
agent.shutdown(timeout=5)
class TestSerenaAgent:
@@ -72,14 +85,10 @@ class TestSerenaAgent:
pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust),
pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript),
pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php),
pytest.param(
Language.CLOJURE,
"greet",
"Function",
clj.CORE_PATH,
marks=[pytest.mark.clojure, pytest.mark.skipif(clj.CLI_FAIL, reason=f"Clojure CLI not available: {clj.CLI_FAIL}")],
),
pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure),
pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell),
],
indirect=["serena_agent"],
)
@@ -127,9 +136,11 @@ class TestSerenaAgent:
"multiply",
clj.CORE_PATH,
clj.UTILS_PATH,
marks=[pytest.mark.clojure, pytest.mark.skipif(clj.CLI_FAIL, reason=f"Clojure CLI not available: {clj.CLI_FAIL}")],
marks=pytest.mark.clojure,
),
pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp),
pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp),
pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell),
],
indirect=["serena_agent"],
)
@@ -284,3 +295,63 @@ class TestSerenaAgent:
symbols = json.loads(result)
assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}"
@pytest.mark.parametrize(
"serena_agent,name_path,num_expected",
[
pytest.param(
Language.JAVA,
"Model/getName",
2,
id="overloaded_java_method",
marks=pytest.mark.java,
),
],
indirect=["serena_agent"],
)
def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, name_path: str, num_expected: int):
"""
Tests whether the FindSymbolTool can find all overloads of a function/method
(provided that the overload id remains unspecified in the name path)
"""
agent = serena_agent
find_symbol_tool = agent.get_tool(FindSymbolTool)
result = find_symbol_tool.apply_ex(
name_path_pattern=name_path,
depth=0,
substring_matching=False,
)
symbols = json.loads(result)
assert (
len(symbols) == num_expected
), f"Expected to find {num_expected} symbols for overloaded function {name_path}. Symbols found: {symbols}"
@pytest.mark.parametrize(
"serena_agent,name_path,relative_path",
[
pytest.param(
Language.JAVA,
"Model/getName",
os.path.join("src", "main", "java", "test_repo", "Model.java"),
id="overloaded_java_method",
marks=pytest.mark.java,
),
],
indirect=["serena_agent"],
)
def test_non_unique_symbol_reference_error(self, serena_agent: SerenaAgent, name_path: str, relative_path: str):
"""
Tests whether the tools operating on a well-defined symbol raises an error when the symbol reference is non-unique.
We exemplarily test a retrieval tool (FindReferencingSymbolsTool) and an editing tool (ReplaceSymbolBodyTool).
"""
match_text = "multiple"
find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool)
with pytest.raises(ValueError, match=match_text):
find_refs_tool.apply(name_path=name_path, relative_path=relative_path)
replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool)
with pytest.raises(ValueError, match=match_text):
replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="")
+142 -13
View File
@@ -24,7 +24,7 @@ from syrupy import SnapshotAssertion
from serena.code_editor import CodeEditor, LanguageServerCodeEditor
from solidlsp.ls_config import Language
from src.serena.symbol import LanguageServerSymbolRetriever
from test.conftest import create_ls, get_repo_path
from test.conftest import get_repo_path, start_ls_context
pytestmark = pytest.mark.snapshot
@@ -201,19 +201,9 @@ class EditingTest(ABC):
if os.name == "nt":
time.sleep(0.1)
log.info(f"Creating language server for {self.language} {self.rel_path}")
language_server = create_ls(self.language, str(self.repo_path))
log.info(f"Starting language server for {self.language} {self.rel_path}")
language_server.start()
log.info(f"Language server started for {self.language} {self.rel_path}")
yield LanguageServerSymbolRetriever(ls=language_server)
with start_ls_context(self.language, str(self.repo_path)) as language_server:
yield LanguageServerSymbolRetriever(ls=language_server)
finally:
if language_server is not None and language_server.is_running():
log.info(f"Stopping language server for {self.language} {self.rel_path}")
language_server.stop()
# attempt at trigger of garbage collection
language_server = None
log.info(f"Language server stopped for {self.language} {self.rel_path}")
# prevent deadlock on Windows due to lingering file locks
if os.name == "nt":
time.sleep(0.1)
@@ -519,3 +509,142 @@ def test_rename_symbol(snapshot: SnapshotAssertion):
"renamed_typed_module_var",
)
test_case.run_test(content_after_ground_truth=snapshot)
# ===== VUE WRITE OPERATIONS TESTS =====
VUE_TEST_FILE = os.path.join("src", "components", "CalculatorButton.vue")
VUE_STORE_FILE = os.path.join("src", "stores", "calculator.ts")
NEW_VUE_HANDLER = """const handleDoubleClick = () => {
pressCount.value++;
emit('click', props.label);
}"""
@pytest.mark.parametrize(
"test_case",
[
pytest.param(
DeleteSymbolTest(
Language.VUE,
VUE_TEST_FILE,
"handleMouseEnter",
),
marks=pytest.mark.vue,
),
],
)
def test_delete_symbol_vue(test_case: DeleteSymbolTest, snapshot: SnapshotAssertion) -> None:
test_case.run_test(content_after_ground_truth=snapshot)
@pytest.mark.parametrize("mode", ["before", "after"])
@pytest.mark.parametrize(
"test_case",
[
pytest.param(
InsertInRelToSymbolTest(
Language.VUE,
VUE_TEST_FILE,
"handleClick",
NEW_VUE_HANDLER,
),
marks=pytest.mark.vue,
),
],
)
def test_insert_in_rel_to_symbol_vue(
test_case: InsertInRelToSymbolTest,
mode: Literal["before", "after"],
snapshot: SnapshotAssertion,
) -> None:
test_case.set_mode(mode)
test_case.run_test(content_after_ground_truth=snapshot)
VUE_REPLACED_HANDLECLICK_BODY = """const handleClick = () => {
if (!props.disabled) {
pressCount.value = 0; // Reset instead of incrementing
emit('click', props.label);
}
}"""
@pytest.mark.parametrize(
"test_case",
[
pytest.param(
ReplaceBodyTest(
Language.VUE,
VUE_TEST_FILE,
"handleClick",
VUE_REPLACED_HANDLECLICK_BODY,
),
marks=pytest.mark.vue,
),
],
)
def test_replace_body_vue(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None:
test_case.run_test(content_after_ground_truth=snapshot)
VUE_REPLACED_PRESSCOUNT_BODY = """const pressCount = ref(100)"""
@pytest.mark.parametrize(
"test_case",
[
pytest.param(
ReplaceBodyTest(
Language.VUE,
VUE_TEST_FILE,
"pressCount",
VUE_REPLACED_PRESSCOUNT_BODY,
),
marks=pytest.mark.vue,
),
],
)
def test_replace_body_vue_with_disambiguation(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None:
"""Test symbol disambiguation when replacing body in Vue files.
This test verifies the fix for the Vue LSP symbol duplication issue.
When the LSP returns two symbols with the same name (e.g., pressCount appears both as
a definition `const pressCount = ref(0)` and as a shorthand property in `defineExpose({ pressCount })`),
the _find_unique_symbol method should prefer the symbol with the larger range (the definition).
The test exercises this by calling replace_body on 'pressCount', which internally calls
_find_unique_symbol and should correctly select the definition (line 40, 19 chars) over
the reference (line 97, 10 chars).
"""
test_case.run_test(content_after_ground_truth=snapshot)
VUE_STORE_REPLACED_CLEAR_BODY = """function clear() {
// Modified: Reset to initial state with a log
console.log('Clearing calculator state');
displayValue.value = '0';
expression.value = '';
operationHistory.value = [];
lastResult.value = undefined;
}"""
@pytest.mark.parametrize(
"test_case",
[
pytest.param(
ReplaceBodyTest(
Language.VUE,
VUE_STORE_FILE,
"clear",
VUE_STORE_REPLACED_CLEAR_BODY,
),
marks=pytest.mark.vue,
),
],
)
def test_replace_body_vue_ts_file(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None:
"""Test that TypeScript files within Vue projects can be edited."""
test_case.run_test(content_after_ground_truth=snapshot)
+3
View File
@@ -5,6 +5,9 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
from test.conftest import language_tests_enabled
pytestmark = [pytest.mark.al, pytest.mark.skipif(not language_tests_enabled(Language.AL), reason="AL tests are disabled")]
@pytest.mark.al
+4
View File
@@ -15,3 +15,7 @@ CLI_FAIL = _test_clojure_cli()
TEST_APP_PATH = Path("src") / "test_app"
CORE_PATH = str(TEST_APP_PATH / "core.clj")
UTILS_PATH = str(TEST_APP_PATH / "utils.clj")
def is_clojure_cli_available() -> bool:
return not CLI_FAIL
+3 -2
View File
@@ -4,12 +4,13 @@ from serena.project import Project
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import UnifiedSymbolInformation
from test.conftest import language_tests_enabled
from . import CLI_FAIL, CORE_PATH, UTILS_PATH
from . import CORE_PATH, UTILS_PATH
@pytest.mark.skipif(not language_tests_enabled(Language.CLOJURE), reason="Clojure tests are disabled")
@pytest.mark.clojure
@pytest.mark.skipif(CLI_FAIL, reason=f"Clojure CLI not available: {CLI_FAIL}")
class TestLanguageServerBasics:
@pytest.mark.parametrize("language_server", [Language.CLOJURE], indirect=True)
def test_basic_definition(self, language_server: SolidLanguageServer):
+6 -13
View File
@@ -1,12 +1,5 @@
import platform
def _test_nextls_available() -> str:
"""Test if Next LS is available and return error reason if not."""
# Check if we're on Windows (Next LS doesn't support Windows)
if platform.system() == "Windows":
return "Next LS does not support Windows"
def _test_expert_available() -> str:
"""Test if Expert is available and return error reason if not."""
# Try to import and check Elixir availability
try:
from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools
@@ -16,13 +9,13 @@ def _test_nextls_available() -> str:
if not elixir_version:
return "Elixir is not installed or not in PATH"
return "" # No error, Next LS should be available
return "" # No error, Expert should be available
except ImportError as e:
return f"Failed to import ElixirTools: {e}"
except Exception as e:
return f"Error checking Next LS availability: {e}"
return f"Error checking Expert availability: {e}"
NEXTLS_UNAVAILABLE_REASON = _test_nextls_available()
NEXTLS_UNAVAILABLE = bool(NEXTLS_UNAVAILABLE_REASON)
EXPERT_UNAVAILABLE_REASON = _test_expert_available()
EXPERT_UNAVAILABLE = bool(EXPERT_UNAVAILABLE_REASON)
+2 -2
View File
@@ -12,10 +12,10 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")]
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")]
class TestElixirBasic:
@@ -1,3 +1,4 @@
import os
from collections.abc import Generator
from pathlib import Path
@@ -5,29 +6,40 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.conftest import create_ls
from test.conftest import start_ls_context
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")]
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Expert not available: {EXPERT_UNAVAILABLE_REASON}")]
# Skip slow tests in CI - they require multiple Expert instances which is too slow
IN_CI = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))
SKIP_SLOW_IN_CI = pytest.mark.skipif(
IN_CI,
reason="Slow tests skipped in CI - require multiple Expert instances (~60-90s each)",
)
@pytest.fixture(scope="module")
@pytest.fixture(scope="session")
def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]:
"""Fixture to set up an LS for the elixir test repo with the 'scripts' directory ignored."""
"""Fixture to set up an LS for the elixir test repo with the 'scripts' directory ignored.
Uses session scope to avoid restarting Expert for each test.
"""
ignored_paths = ["scripts", "ignored_dir"]
ls = create_ls(ignored_paths=ignored_paths, language=Language.ELIXIR)
ls.start()
try:
with start_ls_context(language=Language.ELIXIR, ignored_paths=ignored_paths) as ls:
yield ls
finally:
ls.stop()
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True)
@pytest.mark.slow
@SKIP_SLOW_IN_CI
def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that request_full_symbol_tree ignores the configured directory."""
"""Tests that request_full_symbol_tree ignores the configured directory.
Note: This test uses a separate Expert instance with custom ignored paths,
which adds ~60-90s startup time.
"""
root = ls_with_ignored_dirs.request_full_symbol_tree()[0]
root_children = root["children"]
children_names = {child["name"] for child in root_children}
@@ -39,9 +51,14 @@ def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}"
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True)
@pytest.mark.slow
@SKIP_SLOW_IN_CI
def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that find_references ignores the configured directory."""
"""Tests that find_references ignores the configured directory.
Note: This test uses a separate Expert instance with custom ignored paths,
which adds ~60-90s startup time.
"""
# Location of User struct, which is referenced in scripts and ignored_dir
definition_file = "lib/models.ex"
@@ -64,14 +81,18 @@ def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored"
@pytest.mark.slow
@SKIP_SLOW_IN_CI
@pytest.mark.parametrize("repo_path", [Language.ELIXIR], indirect=True)
def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None:
"""Tests that refs and symbols with glob patterns are ignored."""
"""Tests that refs and symbols with glob patterns are ignored.
Note: This test uses a separate Expert instance with custom ignored paths,
which adds ~60-90s startup time.
"""
ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts
ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.ELIXIR)
ls.start()
with start_ls_context(language=Language.ELIXIR, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls:
try:
# Same as in the above tests
root = ls.request_full_symbol_tree()[0]
root_children = root["children"]
@@ -101,8 +122,6 @@ def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None:
# Assert that scripts and ignored_dir do not appear in references
assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored (glob)"
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)"
finally:
ls.stop()
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
@@ -122,6 +141,10 @@ def test_default_ignored_directories(language_server: SolidLanguageServer):
assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored"
@pytest.mark.xfail(
reason="Expert 0.1.0 bug: document_symbols may return nil for some files (flaky)",
raises=Exception,
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer):
"""Test that symbol tree excludes build and dependency directories."""
@@ -14,10 +14,10 @@ from serena.project import Project
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")]
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")]
class TestElixirIntegration:
@@ -15,15 +15,18 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
from . import EXPERT_UNAVAILABLE, EXPERT_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")]
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(EXPERT_UNAVAILABLE, reason=f"Next LS not available: {EXPERT_UNAVAILABLE_REASON}")]
class TestElixirLanguageServerSymbols:
"""Test the Elixir language server's symbol-related functionality."""
@pytest.mark.xfail(
reason="Expert 0.1.0 bug: document_symbols returns nil for some files (FunctionClauseError in XPExpert.EngineApi.document_symbols/2)"
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol for a function."""
@@ -159,6 +162,9 @@ class TestElixirLanguageServerSymbols:
pass
# Tests for request_defining_symbol
@pytest.mark.xfail(
reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)"
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a function call."""
@@ -183,6 +189,9 @@ class TestElixirLanguageServerSymbols:
if "location" in defining_symbol and "uri" in defining_symbol["location"]:
assert "models.ex" in defining_symbol["location"]["uri"]
@pytest.mark.xfail(
reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)"
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_struct_usage(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a struct usage."""
@@ -205,10 +214,7 @@ class TestElixirLanguageServerSymbols:
assert "User" in defining_symbol.get("name", "")
@pytest.mark.xfail(
reason="Known intermittent bug in Next LS v0.23.3: Protocol.UndefinedError for :timeout atom. "
"Occurs in CI environments but may pass locally. "
"See https://github.com/elixir-tools/next-ls/issues/543",
strict=False,
reason="Expert 0.1.0 bug: definition request crashes (FunctionClauseError in XPExpert.Protocol.Conversions.to_elixir/2)"
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None:
@@ -271,7 +277,7 @@ class TestElixirLanguageServerSymbols:
break
if lib_dir:
# Next LS returns module names instead of file names (e.g., 'services' instead of 'services.ex')
# Expert returns module names instead of file names (e.g., 'services' instead of 'services.ex')
file_names = [child["name"] for child in lib_dir.get("children", [])]
expected_modules = ["models", "services", "examples", "utils", "test_repo"]
found_modules = [name for name in expected_modules if name in file_names]
@@ -284,7 +290,7 @@ class TestElixirLanguageServerSymbols:
# Should get an overview of the lib directory
assert lib_overview is not None
# Next LS returns keys like 'lib/services.ex' instead of just 'lib'
# Expert returns keys like 'lib/services.ex' instead of just 'lib'
overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, "keys") else []
lib_files = [key for key in overview_keys if key.startswith("lib/")]
assert len(lib_files) > 0, f"Expected to find lib/ files in overview keys: {overview_keys}"
@@ -298,10 +304,10 @@ class TestElixirLanguageServerSymbols:
# @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
# def test_request_document_overview(self, language_server: SolidLanguageServer) -> None:
# """Test request_document_overview functionality."""
# # COMMENTED OUT: Next LS document overview doesn't contain expected terms
# # Next LS return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content
# # COMMENTED OUT: Expert document overview doesn't contain expected terms
# # Expert return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content
# # Expected terms like 'user', 'item', 'order', 'struct', 'defmodule' are not present
# # This appears to be a limitation of Next LS document overview functionality
# # This appears to be a limitation of Expert document overview functionality
# #
# file_path = os.path.join("lib", "models.ex")
# doc_overview = language_server.request_document_overview(file_path)
@@ -5,7 +5,7 @@ import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.conftest import create_ls
from test.conftest import start_ls_context
from . import ERLANG_LS_UNAVAILABLE, ERLANG_LS_UNAVAILABLE_REASON
@@ -20,21 +20,8 @@ pytestmark = [
def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]:
"""Fixture to set up an LS for the erlang test repo with the 'ignored_dir' directory ignored."""
ignored_paths = ["_build", "ignored_dir"]
ls = create_ls(ignored_paths=ignored_paths, language=Language.ERLANG)
ls.start()
try:
with start_ls_context(language=Language.ERLANG, ignored_paths=ignored_paths) as ls:
yield ls
finally:
try:
ls.stop(shutdown_timeout=1.0) # Shorter timeout for CI
except Exception as e:
print(f"Warning: Error stopping language server: {e}")
# Force cleanup if needed
if hasattr(ls, "server") and hasattr(ls.server, "process"):
try:
ls.server.process.terminate()
except:
pass
@pytest.mark.timeout(60) # Add 60 second timeout
@@ -87,10 +74,7 @@ def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None:
"""Tests that refs and symbols with glob patterns are ignored."""
ignored_paths = ["_build*", "ignored_*", "*.tmp"]
ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.ERLANG)
ls.start()
try:
with start_ls_context(language=Language.ERLANG, repo_path=str(repo_path), ignored_paths=ignored_paths) as ls:
# Same as in the above tests
root = ls.request_full_symbol_tree()[0]
root_children = root["children"]
@@ -121,17 +105,6 @@ def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None:
# Assert that _build and ignored_dir do not appear in references
assert not any("_build" in ref["relativePath"] for ref in references), "_build should be ignored (glob)"
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)"
finally:
try:
ls.stop(shutdown_timeout=1.0) # Shorter timeout for CI
except Exception as e:
print(f"Warning: Error stopping glob pattern test LS: {e}")
# Force cleanup if needed
if hasattr(ls, "server") and hasattr(ls.server, "process"):
try:
ls.server.process.terminate()
except:
pass
@pytest.mark.parametrize("language_server", [Language.ERLANG], indirect=True)
+219
View File
@@ -0,0 +1,219 @@
import os
import tempfile
import threading
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
@pytest.mark.fsharp
class TestFSharpLanguageServer:
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_find_symbol(self, language_server: SolidLanguageServer) -> None:
"""Test finding symbols in the full symbol tree."""
symbols = language_server.request_full_symbol_tree()
# Check for main program module symbols
assert SymbolUtils.symbol_tree_contains_name(symbols, "Program"), "Program module not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree"
# Check for Calculator module symbols
assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator module not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorClass"), "CalculatorClass not found in symbol tree"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_get_document_symbols_program(self, language_server: SolidLanguageServer) -> None:
"""Test getting document symbols from the main Program.fs file."""
file_path = os.path.join("Program.fs")
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0]
# Look for expected functions and modules
symbol_names = [s.get("name") for s in symbols]
assert "main" in symbol_names, "main function not found in Program.fs symbols"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_get_document_symbols_calculator(self, language_server: SolidLanguageServer) -> None:
"""Test getting document symbols from Calculator.fs file."""
file_path = os.path.join("Calculator.fs")
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0]
# Look for expected functions
symbol_names = [s.get("name") for s in symbols]
expected_symbols = ["add", "subtract", "multiply", "divide", "square", "factorial", "CalculatorClass"]
for expected in expected_symbols:
assert expected in symbol_names, f"{expected} function not found in Calculator.fs symbols"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test finding references using symbol selection range."""
file_path = os.path.join("Calculator.fs")
symbols = language_server.request_document_symbols(file_path)
# Find the 'add' function symbol
add_symbol = None
for sym in symbols.iter_symbols():
if sym.get("name") == "add":
add_symbol = sym
break
assert add_symbol is not None, "Could not find 'add' function symbol in Calculator.fs"
# Try to find references to the add function
sel_start = add_symbol["selectionRange"]["start"]
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1)
# The add function should be referenced in Program.fs
assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference add function"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_nested_module_symbols(self, language_server: SolidLanguageServer) -> None:
"""Test getting symbols from nested Models namespace."""
file_path = os.path.join("Models", "Person.fs")
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0]
# Check for expected types and modules
symbol_names = [s.get("name") for s in symbols]
expected_symbols = ["Person", "PersonModule", "Address", "Employee"]
for expected in expected_symbols:
assert expected in symbol_names, f"{expected} not found in Person.fs symbols"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None:
"""Test finding references to Calculator functions across files."""
# Find the subtract function in Calculator.fs
file_path = os.path.join("Calculator.fs")
symbols = language_server.request_document_symbols(file_path)
subtract_symbol = None
for sym in symbols.iter_symbols():
if sym.get("name") == "subtract":
subtract_symbol = sym
break
assert subtract_symbol is not None, "Could not find 'subtract' function symbol"
# Find references to subtract function
sel_start = subtract_symbol["selectionRange"]["start"]
refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1)
# The subtract function should be referenced in Program.fs
assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference subtract function"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_go_to_definition(self, language_server: SolidLanguageServer) -> None:
"""Test go-to-definition functionality."""
# Test going to definition of 'add' function from Program.fs
program_file = os.path.join("Program.fs")
# Try to find definition of 'add' function used in Program.fs
# This would typically be at the line where 'add 5 3' is called
definitions = language_server.request_definition(program_file, 10, 20) # Approximate position
# We should get at least some definitions
assert len(definitions) >= 0, "Should get definitions (even if empty for complex cases)"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_hover_information(self, language_server: SolidLanguageServer) -> None:
"""Test hover information functionality."""
file_path = os.path.join("Calculator.fs")
# Try to get hover information for a function
hover_info = language_server.request_hover(file_path, 5, 10) # Approximate position of a function
# Hover info might be None or contain information
# This is acceptable as it depends on the LSP server's capabilities and timing
assert hover_info is None or isinstance(hover_info, dict), "Hover info should be None or dict"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_completion(self, language_server: SolidLanguageServer) -> None:
"""Test code completion functionality."""
file_path = os.path.join("Program.fs")
# Use threading for cross-platform timeout (signal.SIGALRM is Unix-only)
result = [None]
exception = [None]
def run_completion():
try:
result[0] = language_server.request_completions(file_path, 15, 10)
except Exception as e:
exception[0] = e
thread = threading.Thread(target=run_completion, daemon=True)
thread.start()
thread.join(timeout=5) # 5 second timeout
if thread.is_alive():
# Completion timed out, but this is acceptable for F# in some cases
# The important thing is that the language server doesn't crash
return
if exception[0]:
raise exception[0]
assert isinstance(result[0], list), "Completions should be a list"
@pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True)
def test_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""Test getting diagnostics (errors, warnings) from F# files."""
file_path = os.path.join("Program.fs")
# FsAutoComplete uses publishDiagnostics notifications instead of textDocument/diagnostic requests
# So we'll test that the language server can handle files without crashing
# In real usage, diagnostics would come through the publishDiagnostics notification handler
# Test that we can at least work with the file (open/close cycle)
with language_server.open_file(file_path) as _:
# If we can open and close the file without errors, basic diagnostics support is working
pass
# This is a successful test - FsAutoComplete is working with F# files
assert True, "F# language server can handle files successfully"
@pytest.mark.fsharp
class TestFSharpLanguageServerSetup:
"""Test F# language server setup and configuration."""
def test_runtime_dependency_setup_without_dotnet(self) -> None:
"""Test that setup fails gracefully when .NET is not installed."""
with patch("shutil.which", return_value=None):
with pytest.raises(RuntimeError, match=r"\.NET SDK is not installed"):
FSharpLanguageServer._setup_runtime_dependencies(Mock(), Mock())
def test_runtime_dependency_setup_with_dotnet(self) -> None:
"""Test that setup works when .NET is available."""
mock_config = Mock()
mock_settings = Mock()
# Mock the ls_resources_dir to return a temp directory
with tempfile.TemporaryDirectory() as temp_dir:
with patch("shutil.which", return_value="/usr/bin/dotnet"):
with patch.object(FSharpLanguageServer, "ls_resources_dir", return_value=temp_dir):
with patch("subprocess.run") as mock_run:
# Mock successful dotnet version check
mock_run.return_value.stdout = "8.0.100"
mock_run.return_value.returncode = 0
# Create a fake fsautocomplete executable
fsharp_dir = os.path.join(temp_dir, "fsharp-lsp")
os.makedirs(fsharp_dir, exist_ok=True)
# Use .exe extension on Windows, matching production code
exe_name = "fsautocomplete.exe" if os.name == "nt" else "fsautocomplete"
fsautocomplete_path = os.path.join(fsharp_dir, exe_name)
Path(fsautocomplete_path).touch()
result = FSharpLanguageServer._setup_runtime_dependencies(mock_config, mock_settings)
assert fsautocomplete_path in result
assert "--adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" in result
+2 -6
View File
@@ -1,17 +1,13 @@
import os
import platform
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
from test.conftest import language_tests_enabled
# These marks will be applied to all tests in this module.
# We skip the tests if not on Windows (CI hangs on Ubuntu for unknown reasons; testing on Windows is sufficient)
is_ci = os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true"
java_tests_enabled = not is_ci or platform.system() == "Windows"
pytestmark = [pytest.mark.java, pytest.mark.skipif(not java_tests_enabled, reason="Java tests are only run on Windows in CI")]
pytestmark = [pytest.mark.java, pytest.mark.skipif(not language_tests_enabled(Language.JAVA), reason="Java tests disabled")]
class TestJavaLanguageServer:
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more