mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 02:14:49 +00:00
Merge branch 'main' into feature/toml-support
# Conflicts: # pyproject.toml
This commit is contained in:
commit
fdd89d0648
109 files changed
+9354
-852
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 $@"]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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>
|
||||
+1486
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
@@ -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
|
||||
|
||||
@@ -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="")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in new issue
Block a user