diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a89e7fd6..724837aa 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 4c36b51a..622b6c4b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -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 diff --git a/.gitignore b/.gitignore index 89bbe0b2..ec2ca442 100644 --- a/.gitignore +++ b/.gitignore @@ -202,20 +202,12 @@ pylint.html /agent-ui /test/**/.serena -# dynamic LS installations -/src/multilspy/language_servers/*/static -/src/solidlsp/language_servers/*/static -/src/solidlsp/language_servers/static - # clojure-lsp temporary files .calva/ .clj-kondo/ .cpcache/ .lsp/ -# Cache is in .multilspy -**/.multilspy/** - # temporary and backup files *.bak *.tmp @@ -259,4 +251,7 @@ test/resources/repos/scala/.bloop/ stack.yaml.lock dist-newstyle/ cabal.project.local* -.ghc.environment.* \ No newline at end of file +.ghc.environment.* + +zz-misc/ +vue-implementation/ \ No newline at end of file diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index 21980531..18d12436 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -21,39 +21,35 @@ Have a look at `intelephense.py` for a reference implementation of a language se dependencies, and on `pyright_server.py` that does not need any additional dependencies because the language server can be installed directly as python package. - ```python from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + class NewLanguageServer(SolidLanguageServer): """ Language server implementation for NewLanguage. """ - - def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + + def __init__(self, config: LanguageServerConfig, repository_root_path: str): # Determine language server command cmd = self._get_language_server_command() - - super().__init__( - config, - logger, - repository_root_path, + + super().__init__(config, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), - "new_language", # Language ID for LSP - ) - + "new_language",, + def _get_language_server_command(self) -> list[str]: """Get the command to start the language server.""" # Example: return ["new-language-server", "--stdio"] pass - + @override def is_ignored_dirname(self, dirname: str) -> bool: """Define language-specific directories to ignore.""" - return super().is_ignored_dirname(dirname) or dirname in ["build", "dist", "target"] + return super().is_ignored_dirname(dirname) or dirname in ["build", + "dist", "target"] ``` ### 1.2 Language Server Discovery and Installation @@ -62,7 +58,7 @@ For languages requiring automatic installation, implement download logic similar ```python @classmethod -def _ensure_server_installed(cls, logger: LanguageServerLogger) -> str: +def _ensure_server_installed(cls) -> str: """Ensure language server is installed and return path.""" # Check system installation first system_server = shutil.which("new-language-server") @@ -70,10 +66,10 @@ def _ensure_server_installed(cls, logger: LanguageServerLogger) -> str: return system_server # Download and install if needed - server_path = cls._download_and_install_server(logger) + server_path = cls._download_and_install_server() return server_path -def _download_and_install_server(cls, logger: LanguageServerLogger) -> str: +def _download_and_install_server(cls) -> str: """Download and install the language server.""" # Implementation specific to your language server pass @@ -129,12 +125,12 @@ In `src/solidlsp/ls.py`, add your language to the `create` method: ```python @classmethod -def create(cls, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str) -> "SolidLanguageServer": +def create(cls, config: LanguageServerConfig, repository_root_path: str) -> "SolidLanguageServer": match config.code_language: # Existing cases... case Language.NEW_LANGUAGE: from solidlsp.language_servers.new_language_server import NewLanguageServer - return NewLanguageServer(config, logger, repository_root_path) + return NewLanguageServer(config, repository_root_path) ``` ## Step 3: Test Repository diff --git a/CHANGELOG.md b/CHANGELOG.md index d8137826..e6228cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ Status of the `main` branch. Changes prior to the next official version change w * Add monorepo/multi-language support * Project configuration files (`project.yml`) can now define multiple languages. Auto-detection adds only the most prominent language by default. - * Additional languages can be conveniently added via the Dashboard while a project is already activated. + * Additional languages can be conveniently added via the Dashboard while a project is already activated. + * The current project can be activated automatically even when the MCP configuration is global (through the --project-from-cwd flag) * Support overloaded symbols in `FindSymbolTool` and related tools * Name paths of overloaded symbols now include an index (e.g., `myOverloadedFunction[2]`) * Responses of the Java language server, which handled this in its own way, are now adapted accordingly, @@ -35,9 +36,11 @@ Status of the `main` branch. Changes prior to the next official version change w * Language support: * **Add support for Fortran** via fortls language server (requires `pip install fortls`) + * **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md)) * **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). @@ -46,11 +49,13 @@ Status of the `main` branch. Changes prior to the next official version change w * **Add support for Lua** via lua-language-server * **Add support for Nix** requires nixd installation (Windows not supported) * **Add experimental support for YAML** via yaml-language-server with LSP integration for .yaml and .yml files + * **Add support for TOML** via Taplo language server with automatic binary download, validation, formatting, and schema support for .toml files * **Dart now officially supported**: Dart was always working, but now tests were added, and it is promoted to "officially supported" * **Rust now uses already installed rustup**: The rust-analyzer is no longer bundled with Serena. Instead, it uses the rust-analyzer from your Rust toolchain managed by rustup. This ensures compatibility with your Rust version and eliminates outdated bundled binaries. * **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage. * **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 diff --git a/CLAUDE.md b/CLAUDE.md index edf91cd1..8f1ef25c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/DOCKER.md b/DOCKER.md index 84f8f746..bb77040c 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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,42 @@ 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 +# Listen on all interfaces for the web dashboard to be accessible from outside the container +web_dashboard_listen_address: 0.0.0.0 +# 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 +61,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 +85,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 +113,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 +172,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. diff --git a/Dockerfile b/Dockerfile index d84ed7d0..1dccaf40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,28 +53,21 @@ 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_listen_address: .*/web_dashboard_listen_address: 0.0.0.0/' $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 +75,3 @@ ENV PATH="/workspaces/serena/.venv/bin:${PATH}" # Entrypoint to ensure environment is activated ENTRYPOINT ["/bin/bash", "-c", "source .venv/bin/activate && $0 $@"] - diff --git a/README.md b/README.md index 92970158..48b17305 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ Instead, it can use code-centric tools like `find_symbol`, `find_referencing_sym Lessons Learned

+> [!TIP] +> The [**Serena JetBrains plugin**](#the-serena-jetbrains-plugin) has been released! + ## LLM Integration Serena provides the necessary [tools](https://oraios.github.io/serena/01-about/035_tools.html) for coding workflows, but an LLM is required to do the actual work, @@ -55,23 +58,47 @@ Note how Serena's tools enable Claude to find and edit the right symbols. https://github.com/user-attachments/assets/6eaa9aa1-610d-4723-a2d6-bf1e487ba753 -### Programming Language Support & Semantic Analysis Capabilities +## Programming Language Support & Semantic Analysis Capabilities -Serena's semantic code analysis capabilities build on **language servers** using the widely implemented -language server protocol (LSP). The LSP provides a set of versatile code querying -and editing functionalities based on symbolic understanding of the code. +Serena provides a set of versatile code querying and editing functionalities +based on symbolic understanding of the code. Equipped with these capabilities, Serena discovers and edits code just like a seasoned developer making use of an IDE's capabilities would. Serena can efficiently find the right context and do the right thing even in very large and -complex projects! So not only is it free and open-source, it frequently achieves better results -than existing solutions that charge a premium. +complex projects! + +There are two alternative technologies powering these capabilities: + +* **Language servers** implementing the language server Protocol (LSP) — the free/open-source alternative. +* **The Serena JetBrains Plugin**, which leverages the powerful code analysis and editing + capabilities of your JetBrains IDE. + +You can choose either of these backends depending on your preferences and requirements. + +### Language Servers + +Serena incorporates a powerful abstraction layer for the integration of language servers +that implement the language server protocol (LSP). +The underlying language servers are typically open-source projects (like Serena) or at least freely available for use. -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, Groovy (partial support), Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, 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. +> Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. + +### The Serena JetBrains Plugin + +As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) +leverages the powerful code analysis capabilities of your JetBrains IDE. +The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs, +including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, CLion, and others. +Only Rider is not supported. + + + +The plugin offers the most robust and most powerful Serena experience. +See our [documentation page](https://oraios.github.io/serena/02-usage/025_jetbrains_plugin.html) for further details and instructions. ## Quick Start diff --git a/compose.yaml b/compose.yaml index 48923e27..d7c8becd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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" diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 764efd14..a748505e 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -1,16 +1,31 @@ # Language Support -Serena's semantic code analysis capabilities build on **language servers** using the widely implemented -language server protocol (LSP). The LSP provides a set of versatile code querying -and editing functionalities based on symbolic understanding of the code. +Serena provides a set of versatile code querying and editing functionalities +based on symbolic understanding of the code. Equipped with these capabilities, Serena discovers and edits code just like a seasoned developer making use of an IDE's capabilities would. Serena can efficiently find the right context and do the right thing even in very large and -complex projects! So not only is it free and open-source, it frequently achieves better results -than existing solutions that charge a premium. +complex projects! -Language servers provide support for a wide range of programming languages. -With Serena, we provide direct, out-of-the-box support for: +There are two alternative technologies powering these capabilities: + +* **Language servers** implementing the language server Protocol (LSP) — the free/open-source alternative. +* **The Serena JetBrains Plugin**, which leverages the powerful code analysis and editing + capabilities of your JetBrains IDE. + +You can choose either of these backends depending on your preferences and requirements. + +## Language Servers + +Serena incorporates a powerful abstraction layer for the integration of language servers +that implement the language server protocol (LSP). +It even supports multiple language servers in parallel to support polyglot projects. + +The language servers themselves are typically open-source projects (like Serena) +or at least freely available for use. + +We currently provide direct, out-of-the-box support for the programming languages listed below. +Some languages require additional installations or setup steps, as noted. * **AL** * **Bash** @@ -20,15 +35,19 @@ 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`) +* **Groovy** + (requires local groovy-language-server.jar setup via GROOVY_LS_JAR_PATH or configuration) * **Haskell** (automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects) * **Java** @@ -56,8 +75,42 @@ 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) Support for further languages can easily be added by providing a shallow adapter for a new language server implementation, see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena/memories/adding_new_language_support_guide.md). + +## The Serena JetBrains Plugin + +As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) +leverages the powerful code analysis capabilities of JetBrains IDEs. +The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs, +including IntelliJ IDEA, PyCharm, Android Studio, AppCode, WebStorm, PhpStorm, RubyMine, GoLand, AppCode, CLion, Rider, and others. + +When using the plugin, Serena connects to an instance of your JetBrains IDE via the plugin. For users who already +work in a JetBrains IDE, this means Serena seamlessly integrates with the IDE instance you typically have open anyway, +requiring no additional setup or configuration beyond the plugin itself. This approach offers several key advantages: + +* **External library indexing**: Dependencies and libraries are fully indexed and accessible to Serena +* **No additional setup**: No need to download or configure separate language servers +* **Enhanced performance**: Faster tool execution thanks to optimized IDE integration +* **Multi-language excellence**: First-class support for polyglot projects with multiple languages and frameworks + +Even if you prefer to work in a different code editor, you can still benefit from the JetBrains plugin by running +a JetBrains IDE instance (most have free community editions) alongside your preferred editor with your project +opened and indexed. Serena will connect to the IDE for code analysis while you continue working in your editor +of choice. + +```{raw} html +

+ + + +

+``` + +See the [JetBrains Plugin documentation](../02-usage/025_jetbrains_plugin) for usage details. \ No newline at end of file diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index ede78ec2..10dce262 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -2,14 +2,20 @@ Serena is a command-line tool with a variety of sub-commands. This section describes - * varies ways of running Serena + * various ways of running Serena * how to run and configure the most important command, i.e. starting the MCP server * other useful commands. ## Ways of Running Serena In the following, we will refer to the command used to run Serena as ``, -which you should replace with the appropriate command based on your chosen method. +which you should replace with the appropriate command based on your chosen method, +as detailed below. + +In general, to get help, append `--help` to the command, i.e. + + --help + --help ### Using uvx @@ -47,28 +53,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 @@ -90,6 +98,12 @@ Given your preferred method of running Serena, you can start the MCP server usin Note that no matter how you run the MCP server, Serena will, by default, start a web-based dashboard on localhost that will allow you to inspect the server's operations, logs, and configuration. +:::{tip} +By default, Serena will use language servers for code understanding and analysis. +With the [Serena JetBrains Plugin](025_jetbrains_plugin), we recently introduced a powerful alternative, +which has several advantages over the language server-based approach. +::: + ### Standard I/O Mode The typical usage involves the client (e.g. Claude Code, Codex or Cursor) running @@ -148,6 +162,11 @@ to get a list of all available options. Some useful options include: * `--project `: specify the project to work on by name or path. + * `--project-from-cwd`: auto-detect the project from current working directory + (looking for a directory containing `.serena/project.yml` or `.git` in parent directories, activating the current directory if none is found); + This option is intended for CLI-based agents like Claude Code, Gemini and Codex, which are typically started from within the project directory + and which do not change directories during their operation. + * `--language-backend JetBrains`: use the Serena JetBrains Plugin as the language backend (overriding the default backend configured in the central configuration) * `--context `: specify the operation [context](contexts) in which Serena shall operate * `--mode `: specify one or more [modes](modes) to enable (can be passed several times) * `--enable-web-dashboard `: enable or disable the web dashboard (enabled by default) @@ -225,4 +244,4 @@ Here are some examples of commands you might find useful: prompts edit-override prompt-name ``` -Explore the full set of commands and options using the CLI itself! \ No newline at end of file +Explore the full set of commands and options using the CLI itself! diff --git a/docs/02-usage/025_jetbrains_plugin.md b/docs/02-usage/025_jetbrains_plugin.md new file mode 100644 index 00000000..52343d91 --- /dev/null +++ b/docs/02-usage/025_jetbrains_plugin.md @@ -0,0 +1,56 @@ +# The Serena JetBrains Plugin + +The [JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) allows Serena to +leverage the powerful code analysis and editing capabilities of your JetBrains IDE. + +```{raw} html +

+ + + +

+``` + +We recommend the JetBrains plugin as the preferred way of using Serena, +especially for users of JetBrains IDEs. + +**Purchasing the JetBrains Plugin supports the Serena project.** +The proceeds from plugin sales allow us to dedicate more resources to further developing and improving Serena. + +## Configuring Serena + +After installing the plugin, you need to configure Serena to use it. + +**Central Configuration**. +Edit the global Serena configuration file located at `~/.serena/serena_config.yml` +(`%USERPROFILE%\.serena\config.yaml` on Windows). +Change the `language_backend` setting as follows: + +```yaml +language_backend: JetBrains +``` + +**Per-Instance Configuration**. +The configuration setting in the global config file can be overridden on a +per-instance basis by providing the arguments `--language-backend JetBrains` when +launching the Serena MCP server. + +## Advantages of the JetBrains Plugin + +There are multiple features that are only available when using the JetBrains plugin: + +* **External library indexing**: Dependencies and libraries are fully indexed and accessible to Serena +* **No additional setup**: No need to download or configure separate language servers +* **Enhanced performance**: Faster tool execution thanks to optimized IDE integration +* **Multi-language excellence**: First-class support for polyglot projects with multiple languages and frameworks + +We are also working on additional features like a `move_symbol` tool and debugging-related capabilities that +will be available exclusively through the JetBrains plugin. + +## Usage with Other Editors + +We realize that not everyone uses a JetBrains IDE as their main code editor. +You can still take advantage of the JetBrains plugin by running a JetBrains IDE instance alongside your +preferred editor. Most JetBrains IDEs have a free community edition that you can use for this purpose. +You just need to make sure that the project you are working on is open and indexed in the JetBrains IDE, +so that Serena can connect to it. diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index a323f3b8..b757934b 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -1,10 +1,75 @@ # Connecting Your MCP Client -In the following, we provide default configurations for popular MCP-enabled clients. +In the following, we provide general instructions on how to connect Serena to your MCP-enabled client, +as well as specific instructions for popular clients. -Depending on your needs, you might want to customize Serena's behaviour by - * [adding command-line arguments](mcp-args) - * [adjusting configuration](050_configuration). +:::{note} +The configurations we provide for particular clients below will run the latest version of Serena +using the `stdio` protocol with `uvx`. +Adapt the commands to your preferred way of [running Serena](020_running), adding any additional +command-line arguments as needed. +::: + +(clients-general-instructions)= +## General Instructions + +In general, Serena can be used with any MCP-enabled client. +To connect Serena to your favourite client, simply + +1. determine how to add a custom MCP server to your client (refer to the client's documentation). +2. add a new MCP server entry by specifying either + * a [run command](start-mcp-server) that allows the client to start the MCP server in stdio mode as a subprocess, or + * the URL of the HTTP/SSE endpoint, having started the [Serena MCP server in HTTP/SSE mode](streamable-http) beforehand. + +Find concrete examples for popular clients below. + +Depending on your needs, you might want to further customize Serena's behaviour by +* [adding command-line arguments](mcp-args) +* [adjusting configuration](050_configuration). + +**Mode of Operation**. +Note that some clients have a per-workspace MCP configuration (e.g, VSCode and Claude Code), +while others have a global MCP configuration (e.g. Codex and Claude Desktop). + +- In the per-workspace case, you typically want to start Serena with your workspace directory as the project directory + and never switch to a different project. This is achieved by specifying the + `--project ` argument with a single-project [context](#contexts) (e.g. `ide` or `claude-code`). +- In the global configuration case, you must first activate the project you want to work on, which you can do by asking + the LLM to do so (e.g., "Activate the current dir as project using serena"). In such settings, the `activate_project` + tool is required. + +**Tool Selection**. +While you may be able to turn off tools through your client's interface (e.g., in VSCode or Claude Desktop), +we recommend selecting your base tool set through Serena's configuration, as Serena's prompts automatically +adjust based on which tools are enabled/disabled. +A key mechanism for this is to use the appropriate [context](#contexts) when starting Serena. + +(clients-common-pitfalls)= +### Common Pitfalls + +**Escaping Paths Correctly**. +Note that if your client configuration uses JSON, special characters (like backslashes) need to be escaped properly. +In particular, if you are specifying paths containing backslashes on Windows +(note that you can also just use forward slashes), be sure to escape them correctly (`\\`). + +**Discoverability of `uvx`**. +Your client may not find the `uvx` command, even if it is on your system PATH. +In this case, a workaround is to provide the full path to the `uvx` executable. + +**Environment Variables**. +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 for some clients (e.g. Claude Desktop), the spawned MCP server process may 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. +For example, in Claude Desktop and other clients, you can simply add an `env` key to the `serena` +object, e.g. + +``` +"env": { + "DOTNET_ROOT": "/opt/homebrew/Cellar/dotnet/9.0.8/libexec" +} +``` ## Claude Code @@ -13,25 +78,57 @@ 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 -- start-mcp-server --context ide-assistant --project "$(pwd)" -``` - -where `` 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 Claude Code's mode of operation. - + +Alternatively, use `--project-from-cwd` for user-level configuration that works across all projects: + +```shell +claude mcp add --scope user serena -- start-mcp-server --context=claude-code --project-from-cwd +``` + +This searches up from the current directory for `.serena/project.yml` or `.git` markers, +falling back to the current directory if neither is found. This makes it suitable for a +single global MCP configuration. The `--project-from-cwd` option is intended for CLI-based agents +(like Claude Code, Gemina or Codex) that invoke Serena from within project directories. + Be sure to use at least `v1.0.52` of Claude Code (as earlier versions do not read MCP server system prompts upon startup). +## VSCode + +While serena can be directly installed from the GitHub MCP server registry, we recommend to set it up manually +(at least for now, until the configuration there has been improved). Just paste the following into +`/.vscode/mcp.json`, or edit the entry after using the option `install into workspace`: + +```json +{ + "servers": { + "oraios/serena": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide", + "--project", + "${workspaceFolder}" + ] + } + }, + "inputs": [] +} +``` + ## Codex Serena works with OpenAI's Codex CLI out of the box, but you have to use the `codex` context for it to work properly. (The technical reason is that Codex doesn't fully support the MCP specifications, so some massaging of tools is required.). @@ -45,6 +142,7 @@ For example, when using `uvx`, add the following section: command = "uvx" args = ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server", "--context", "codex"] ``` + After codex has started, you need to activate the project, which you can do by saying: "Activate the current dir as project using serena" @@ -61,77 +159,132 @@ that was already taken). ## Claude Desktop -On Windows and macOS there are official [Claude Desktop applications by Anthropic](https://claude.ai/download), for Linux there is an [open-source +On Windows and macOS, there are official [Claude Desktop applications by Anthropic](https://claude.ai/download); for Linux, there is an [open-source community version](https://github.com/aaddrick/claude-desktop-debian). To configure MCP server settings, go to File / Settings / Developer / MCP Servers / Edit Config, which will let you open the JSON file `claude_desktop_config.json`. -Add the `serena` MCP server configuration, using a [run command](020_running.md) depending on your setup. +Add the `serena` MCP server configuration -* local installation: - - ```json - { - "mcpServers": { - "serena": { - "command": "/abs/path/to/uv", - "args": ["run", "--directory", "/abs/path/to/serena", "serena", "start-mcp-server"] - } - } - } - ``` - -* uvx: - - ```json - { - "mcpServers": { - "serena": { - "command": "/abs/path/to/uvx", - "args": ["--from", "git+https://github.com/oraios/serena", "serena", "start-mcp-server"] - } - } +```json +{ + "mcpServers": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server" + ] + } } - ``` +} +``` -* docker: +If your language server requires specific environment variables to be set (e.g. F# on macOS with Homebrew), +you can add them via an `env` key (see [above](#clients-common-pitfalls)). - ```json - { - "mcpServers": { - "serena": { - "command": "docker", - "args": ["run", "--rm", "-i", "--network", "host", "-v", "/path/to/your/projects:/workspaces/projects", "ghcr.io/oraios/serena:latest", "serena", "start-mcp-server", "--transport", "stdio"] - } - } - } - ``` - -If you are using paths containing backslashes for paths on Windows -(note that you can also just use forward slashes), be sure to escape them correctly (`\\`). - -That's it! Save the config and then restart Claude Desktop. You are ready for activating your first project. +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. +Be sure to fully quit the Claude Desktop application via File / Exit, as regularly closing the application will just +minimize it. ::: After restarting, you should see Serena's tools in your chat interface (notice the small hammer icon). -For more information on MCP servers with Claude Desktop, see [the official quick start guide](https://modelcontextprotocol.io/quickstart/user). +For more information on MCP servers with Claude Desktop, +see [the official quick start guide](https://modelcontextprotocol.io/quickstart/user). + +## JetBrains Junie + +Open Junie, go to the three dots in the top right corner, then Settings / MCP Settings and add Serena to Junie's global +MCP server configuration: + +```json +{ + "mcpServers": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide" + ] + } + } +} +``` + +You will have to prompt Junie to "Activate the current project using serena's activation tool" at the +start of each session. + +## JetBrains AI Assistant + +Here you can set up the more convenient per-project MCP server configuration, as the AI assistant supports specifying +the launch working directory. + +Go to Settings / Tools / AI Assistant / MCP and add a new **local** configuration via the `as JSON` option: + +```json +{ + "mcpServers": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide", + "--project", + "$(pwd)" + ] + } + } +} +``` + +Then make sure to configure the working directory to be the project root. + +## Antigravity + +:::{warning} +At the time of writing (12/2025), Antigravity does not seem to work with Serena due to schema validation issues +which are beyond our control. +The client starts Serena but then crashes with `[internal] marshal message: string field contains invalid UTF-8`. +Nevertheless, we provide a configuration that should work once the issue is resolved. +::: + +Add this configuration: + +```json +{ + "mcpServers": { + "serena": { + "command": "uvx", + "args": [ + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide" + ] + } + } +} +``` ## Other Clients -In general, Serena can be used with any MCP-enabled client. -To connect Serena to your favourite client, simply - -1. determine how to add a custom MCP server to your client (refer to the client's documentation). -2. add a new MCP server entry by specifying either - * a [run command](start-mcp-server) that allows the client to start the MCP server in stdio mode as a subprocess, or - * the URL of the HTTP/SSE endpoint, having started the [Serena MCP server in HTTP/SSE mode](streamable-http) beforehand. - -Below, we list some popular categories of clients that Serena can be used with. +For other clients, follow the [general instructions](#clients-general-instructions) above to set up Serena as an MCP server. ### Terminal-Based Clients @@ -146,12 +299,15 @@ There are many terminal-based coding assistants that support MCP servers, such a They generally benefit from the symbolic tools provided by Serena. You might want to customize some aspects of Serena by writing your own context, modes or prompts to adjust it to the client's respective internal capabilities (and your general workflow). +In most cases, the `ide` context is likely to be appropriate for such clients, i.e. add the arguments `--context ide` +in order to reduce tool duplication. + ### MCP-Enabled IDEs and Coding Clients (Cline, Roo-Code, Cursor, Windsurf, etc.) 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 diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index 35afb55e..4544b25b 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -55,18 +55,18 @@ Indexing has to be called only once. During regular usage, Serena will automatic Project activation makes Serena aware of the project you want to work with. You can either choose to do this - * while in a conversation, by telling the model to activate a project, e.g., + * while in a conversation, by telling the LLM to activate a project, e.g., * "Activate the project /path/to/my_project" (for first-time activation with auto-creation) * "Activate the project my_project" Note that this option requires the `activate_project` tool to be active, - which it isn't in the (default version) of context `ide-assistant` if a project is provided at startup. - (The tool is deactivated because we assume that in the ide-assistant context the user will only work on the open project and have + which it isn't in single-project [contexts](contexts) like `ide` or `claude-code` *if* a project is provided at startup. + (The tool is deactivated, because we assume that in these contexts, user will only work on the single, open project and have no need to switch it.) * when the MCP server starts, by passing the project path or name as a command-line argument - (e.g. when working on a fixed project in `ide-assistant` mode): `--project ` + (e.g. when using a single-project mode like `ide` or `claude-code`): `--project ` ## Onboarding & Memories diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 341c53d7..1a62f6b0 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -8,14 +8,15 @@ Serena is configured in four places: 1. The `serena_config.yml` for general settings that apply to all clients and projects. It is located in your user directory under `.serena/serena_config.yml`. - If you do not explicitly create the file, it will be auto-generated when you first run Serena. + The file will be auto-generated when you first run Serena. You can edit it directly or use ```shell - uvx --from git+https://github.com/oraios/serena serena config edit + config edit ``` - (or use the `--directory` command version). + where `` is [your way of running Serena](020_running). + The configuration file can also be accessed through [Serena's dashboard](060_dashboard). 2. In the arguments passed to the `start-mcp-server` in your client's config (see below), which will apply to all sessions started by the respective client. In particular, the [context](contexts) parameter should be set appropriately for Serena to be best adjusted to existing tools and capabilities of your client. @@ -44,9 +45,22 @@ 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 the above contexts [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/contexts). + +Note that the contexts `ide` and `claude-code` are **single-project contexts** (defining `single_project: true`). +For such contexts, if 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. Since changing the active project +ceases to be a relevant operation in this case, the project activation tool is disabled. When launching Serena, specify the context using `--context `. Note that for cases where parameter lists are specified (e.g. Claude Desktop), you must add two parameters to the list. @@ -74,18 +88,34 @@ 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). + +:::{important} +By default, Serena activates the two modes `interactive` and `editing`. + +As soon as you start to specify modes, only the modes you explicitly specify will be active, however. +Therefore, if you want to keep the default modes, you must specify them as well. +For example, to add mode `no-memories` to the default behaviour, specify +```shell +--mode interactive --mode editing --mode no-memories +``` +::: 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 `; 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`). +:::{note} +**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, mode --help mode list @@ -93,4 +123,31 @@ You can manage contexts using the `mode` command, mode edit mode delete -where `` is [your way of running Serena](020_running). \ No newline at end of file +where `` 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. diff --git a/docs/03-special-guides/groovy_setup_guide_for_serena.md b/docs/03-special-guides/groovy_setup_guide_for_serena.md new file mode 100644 index 00000000..50e5be23 --- /dev/null +++ b/docs/03-special-guides/groovy_setup_guide_for_serena.md @@ -0,0 +1,88 @@ +# Groovy Setup Guide for Serena + +The Groovy support in Serena is incomplete and requires the user to provide a functioning, +JVM-based Groovy language server as a jar. This intermediate state allows the contributors +of Groovy support to use Serena internally and hopefully to accelerate their open-source +release of a Groovy language server in the future. + +If you happen to have a Groovy language server JAR file, you can configure Serena to use it +by following the instructions below. + +--- +## Prerequisites + +- Groovy Language Server JAR file + - Can be any open-source Groovy language server or your custom implementation + - The JAR must be compatible with standard LSP protocol + +--- +## Configuration + +Configure Groovy Language Server by adding settings to your `~/.serena/serena_config.yml`: + +### Basic Configuration + +```yaml +ls_specific_settings: + groovy: + ls_jar_path: '/path/to/groovy-language-server.jar' + ls_jar_options: '-Xmx2G -Xms512m' +``` + +### Custom Java Paths + +If you have specific Java installations: + +```yaml +ls_specific_settings: + groovy: + ls_jar_path: '/path/to/groovy-language-server.jar' + ls_java_home_path: '/usr/lib/jvm/java-21-openjdk' # Custom JAVA_HOME directory + ls_jar_options: '-Xmx2G -Xms512m' # Optional JVM options +``` + +### Configuration Options + +- `ls_jar_path`: Absolute path to your Groovy Language Server JAR file (required) +- `ls_java_home_path`: Custom JAVA_HOME directory for Java installation (optional) + - When specified, Serena will use this Java installation instead of downloading bundled Java + - Java executable path is automatically determined based on platform: + - Windows: `{ls_java_home_path}/bin/java.exe` + - Linux/macOS: `{ls_java_home_path}/bin/java` + - Validates that Java executable exists at the expected location +- `ls_jar_options`: JVM options for language server (optional) + - Common options: + - `-Xmx`: Maximum heap size (e.g., `-Xmx2G` for 2GB) + - `-Xms`: Initial heap size (e.g., `-Xms512m` for 512MB) + +--- +## Project Structure Requirements + +For optimal Groovy Language Server performance, ensure your project follows standard Groovy/Gradle structure: + +``` +project-root/ +├── src/ +│ ├── main/ +│ │ ├── groovy/ +│ │ └── resources/ +│ └── test/ +│ ├── groovy/ +│ └── resources/ +├── build.gradle or build.gradle.kts +├── settings.gradle or settings.gradle.kts +└── gradle/ + └── wrapper/ +``` + +--- +## Using Serena with Groovy + +- Serena automatically detects Groovy files (`*.groovy`, `*.gvy`) and will start a Groovy Language Server JAR process per project when needed. +- Optimal results require that your project compiles successfully via Gradle or Maven. If compilation fails, fix build errors in your build tool first. + +## Reference + +- **Groovy Documentation**: [https://groovy-lang.org/documentation.html](https://groovy-lang.org/documentation.html) +- **Gradle Documentation**: [https://docs.gradle.org](https://docs.gradle.org) +- **Serena Configuration**: [../02-usage/050_configuration.md](../02-usage/050_configuration.md) \ No newline at end of file diff --git a/docs/_config.yml b/docs/_config.yml index dbea0794..89a0f6c2 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -53,7 +53,7 @@ parse: ####################################################################################### # HTML-specific settings html: - #favicon : # A path to a favicon image + favicon : "../src/serena/resources/dashboard/serena-icon-32.png" use_edit_page_button : false # Whether to add an "edit this page" button to pages. If `true`, repository information in repository: must be filled in use_repository_button : false # Whether to add a link to your repository button use_issues_button : false # Whether to add an "open an issue" button diff --git a/docs/_static/images/jetbrains-marketplace-button.png b/docs/_static/images/jetbrains-marketplace-button.png new file mode 100644 index 00000000..fd62f400 Binary files /dev/null and b/docs/_static/images/jetbrains-marketplace-button.png differ diff --git a/public/.gitignore b/public/.gitignore deleted file mode 100644 index e69de29b..00000000 diff --git a/pyproject.toml b/pyproject.toml index 618d1b3f..7c44e907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "fortls>=3.2.2", "overrides>=7.7.0,<8", "python-dotenv>=1.0.0, <2", - "mcp==1.12.3", + "mcp==1.23.0", "flask>=3.0.0", "sensai-utils>=1.5.0", "pydantic>=2.10.6", @@ -72,6 +72,7 @@ dev = [ "jupyter-book>=1,<2", "nbsphinx", "pyinstrument", + "pytest-timeout>=2.4.0", ] agno = ["agno>=2.2.1", "sqlalchemy>=2.0.40"] google = ["google-genai>=1.8.0"] @@ -121,7 +122,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" @@ -273,8 +274,10 @@ markers = [ "go: language server running for Go", "java: language server running for Java", "kotlin: language server running for kotlin", + "groovy: language server running for Groovy", "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 +296,16 @@ 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", ] [tool.codespell] @@ -306,4 +313,4 @@ markers = [ skip = '.git*,*.svg,*.lock,*.min.*' check-hidden = true # ignore-regex = '' -# ignore-words-list = '' +ignore-words-list = 'paket' \ No newline at end of file diff --git a/resources/jetbrains-marketplace-button.cdr b/resources/jetbrains-marketplace-button.cdr new file mode 100644 index 00000000..9c5daae9 Binary files /dev/null and b/resources/jetbrains-marketplace-button.cdr differ diff --git a/resources/vscode_sponsor_logo.png b/resources/vscode_sponsor_logo.png index ebd8f317..5f253cfd 100644 Binary files a/resources/vscode_sponsor_logo.png and b/resources/vscode_sponsor_logo.png differ diff --git a/scripts/profile_tool_call.py b/scripts/profile_tool_call.py index e0e69981..8fe922a5 100644 --- a/scripts/profile_tool_call.py +++ b/scripts/profile_tool_call.py @@ -25,7 +25,6 @@ if __name__ == "__main__": serena_config = SerenaConfig.from_config_file() serena_config.log_level = logging.INFO - serena_config.jetbrains = False serena_config.gui_log_window_enabled = False serena_config.web_dashboard = False diff --git a/src/serena/agent.py b/src/serena/agent.py index 70d5bb73..02d44079 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -18,13 +18,14 @@ from interprompt.jinja_template import JinjaTemplate from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition +from serena.config.serena_config import LanguageBackend, SerenaConfig, ToolInclusionDefinition from serena.dashboard import SerenaDashboardAPI from serena.ls_manager import LanguageServerManager from serena.project import Project from serena.prompt_factory import SerenaPromptFactory from serena.task_executor import TaskExecutor from serena.tools import ActivateProjectTool, GetCurrentConfigTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry +from serena.util.gui import system_has_usable_display from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language @@ -204,6 +205,7 @@ class SerenaAgent: # open GUI log window if enabled self._gui_log_viewer: Optional["GuiLogViewer"] = None if self.serena_config.gui_log_window_enabled: + log.info("Opening GUI window") if platform.system() == "Darwin": log.warning("GUI log window is not supported on macOS") else: @@ -213,6 +215,8 @@ class SerenaAgent: self._gui_log_viewer = GuiLogViewer("dashboard", title="Serena Logs", memory_log_handler=get_memory_log_handler()) self._gui_log_viewer.start() + else: + log.debug("GUI window is disabled") # set the agent context if context is None: @@ -232,7 +236,10 @@ class SerenaAgent: self._tool_usage_stats = ToolUsageStats(token_count_estimator) # log fundamental information - log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})") + log.info( + f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()}; " + f"language backend={self.serena_config.language_backend.name})" + ) log.info("Configuration file: %s", self.serena_config.config_file_path) log.info("Available projects: {}".format(", ".join(self.serena_config.project_names))) log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}") @@ -244,7 +251,7 @@ class SerenaAgent: tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context] if self._context.single_project: tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project)) - if self.serena_config.jetbrains: + if self.serena_config.language_backend == LanguageBackend.JETBRAINS: tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions) @@ -280,15 +287,21 @@ 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 - # output redirection) - process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) - process.start() - process.join(timeout=1) + if not system_has_usable_display(): + log.warning("Not opening the Serena web dashboard automatically because no usable display was detected.") + else: + # open the dashboard URL in the default web browser (using a separate process to control + # output redirection) + process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) + process.start() + process.join(timeout=1) # inform the GUI window (if any) if self._gui_log_viewer is not None: self._gui_log_viewer.set_dashboard_url(dashboard_url) @@ -517,7 +530,7 @@ class SerenaAgent: """ :return: whether this agent uses language server-based code analysis """ - return not self.serena_config.jetbrains + return self.serena_config.language_backend == LanguageBackend.LSP def _activate_project(self, project: Project) -> None: log.info(f"Activating {project.project_name} at {project.project_root}") @@ -684,18 +697,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) diff --git a/src/serena/cli.py b/src/serena/cli.py index dc47d9b4..bc4af3e6 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -5,6 +5,7 @@ import os import shutil import subprocess import sys +from collections.abc import Iterator from logging import Logger from pathlib import Path from typing import Any, Literal @@ -17,18 +18,14 @@ from tqdm import tqdm from serena.agent import SerenaAgent from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths +from serena.config.serena_config import LanguageBackend, 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 @@ -39,6 +36,42 @@ from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) + +def find_project_root(root: str | Path | None = None) -> str: + """Find project root by walking up from CWD. + + Checks for .serena/project.yml first (explicit Serena project), then .git (git root). + Falls back to CWD if no marker is found. + + :param root: If provided, constrains the search to this directory and below + (acts as a virtual filesystem root). Search stops at this boundary. + :return: absolute path to project root (falls back to CWD if no marker found) + """ + current = Path.cwd().resolve() + boundary = Path(root).resolve() if root is not None else None + + def ancestors() -> Iterator[Path]: + """Yield current directory and ancestors up to boundary.""" + yield current + for parent in current.parents: + yield parent + if boundary is not None and parent == boundary: + return + + # First pass: look for .serena + for directory in ancestors(): + if (directory / ".serena" / "project.yml").is_file(): + return str(directory) + + # Second pass: look for .git + for directory in ancestors(): + if (directory / ".git").exists(): # .git can be file (worktree) or dir + return str(directory) + + # Fall back to CWD + return str(current) + + # --------------------- Utilities ------------------------------------- @@ -121,6 +154,12 @@ class TopLevelCommands(AutoRegisteringGroup): show_default=True, help="Built-in mode names or paths to custom mode YAMLs.", ) + @click.option( + "--language-backend", + type=click.Choice([lb.value for lb in LanguageBackend]), + default=None, + help="Override the configured language backend.", + ) @click.option( "--transport", type=click.Choice(["stdio", "sse", "streamable-http"]), @@ -128,8 +167,16 @@ class TopLevelCommands(AutoRegisteringGroup): show_default=True, help="Transport protocol.", ) - @click.option("--host", type=str, default="0.0.0.0", show_default=True) - @click.option("--port", type=int, default=8000, show_default=True) + @click.option( + "--host", + type=str, + default="0.0.0.0", + show_default=True, + help="Listen address for the MCP server (when using corresponding transport).", + ) + @click.option( + "--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)." + ) @click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.") @click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.") @click.option( @@ -140,11 +187,19 @@ class TopLevelCommands(AutoRegisteringGroup): ) @click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.") @click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.") + @click.option( + "--project-from-cwd", + is_flag=True, + default=False, + help="Auto-detect project from current working directory (searches for .serena/project.yml or .git, falls back to CWD). Intended for CLI-based agents like Claude Code, Gemini and Codex.", + ) def start_mcp_server( project: str | None, project_file_arg: str | None, + project_from_cwd: bool | None, context: str, modes: tuple[str, ...], + language_backend: str | None, transport: Literal["stdio", "sse", "streamable-http"], host: str, port: int, @@ -173,12 +228,21 @@ class TopLevelCommands(AutoRegisteringGroup): log.info("Initializing Serena MCP server") log.info("Storing logs in %s", log_path) + + # Handle --project-from-cwd flag + if project_from_cwd: + if project is not None or project_file_arg is not None: + raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument") + project = find_project_root() + log.info("Auto-detected project root: %s", project) + project_file = project_file_arg or project factory = SerenaMCPFactorySingleProcess(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( host=host, port=port, modes=modes, + language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, enable_web_dashboard=enable_web_dashboard, enable_gui_log_window=enable_gui_log_window, log_level=log_level, @@ -270,7 +334,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 +353,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 +369,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 +411,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 +430,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 +446,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 +465,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 +874,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 +927,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) diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 6e9f65b1..4f5fe6bb 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Generic, Optional, TypeVar, cast from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol from solidlsp import SolidLanguageServer, ls_types from solidlsp.ls import LSPFileBuffer -from solidlsp.ls_types import extract_text_edits from solidlsp.ls_utils import PathUtils, TextUtils from .constants import DEFAULT_SOURCE_FILE_ENCODING @@ -38,12 +37,23 @@ class CodeEditor(Generic[TSymbol], ABC): self.encoding = encoding class EditedFile(ABC): + def __init__(self, relative_path: str) -> None: + self.relative_path = relative_path + @abstractmethod def get_contents(self) -> str: """ :return: the contents of the file. """ + @abstractmethod + def set_contents(self, contents: str) -> None: + """ + Fully resets the contents of the file. + + :param contents: the new contents + """ + @abstractmethod def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: pass @@ -60,16 +70,19 @@ class CodeEditor(Generic[TSymbol], ABC): raise NotImplementedError("This method must be overridden for each subclass") @contextmanager - def _edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: + def edited_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: """ Context manager for editing a file. """ with self._open_file_context(relative_path) as edited_file: yield edited_file # save the file - abs_path = os.path.join(self.project_root, relative_path) - with open(abs_path, "w", encoding=self.encoding) as f: - f.write(edited_file.get_contents()) + self._save_edited_file(edited_file) + + def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None: + abs_path = os.path.join(self.project_root, edited_file.relative_path) + with open(abs_path, "w", encoding=self.encoding) as f: + f.write(edited_file.get_contents()) @abstractmethod def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> TSymbol: @@ -94,7 +107,7 @@ class CodeEditor(Generic[TSymbol], ABC): start_pos = symbol.get_body_start_position_or_raise() end_pos = symbol.get_body_end_position_or_raise() - with self._edited_file_context(relative_file_path) as edited_file: + with self.edited_file_context(relative_file_path) as edited_file: # make sure the replacement adds no additional newlines (before or after) - all newlines # and whitespace before/after should remain the same, so we strip it entirely body = body.strip() @@ -149,7 +162,7 @@ class CodeEditor(Generic[TSymbol], ABC): # `line += 1`, is replaced body = body.rstrip("\r\n") + "\n" - with self._edited_file_context(relative_file_path) as edited_file: + with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, col), body) def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str) -> None: @@ -177,7 +190,7 @@ class CodeEditor(Generic[TSymbol], ABC): body += "\n" * num_trailing_newlines # apply edit - with self._edited_file_context(relative_file_path) as edited_file: + with self.edited_file_context(relative_file_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line=line, col=col), body) def insert_at_line(self, relative_path: str, line: int, content: str) -> None: @@ -188,7 +201,7 @@ class CodeEditor(Generic[TSymbol], ABC): :param line: the 0-based index of the line to insert content at :param content: the content to insert """ - with self._edited_file_context(relative_path) as edited_file: + with self.edited_file_context(relative_path) as edited_file: edited_file.insert_text_at_position(PositionInFile(line, 0), content) def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None: @@ -202,7 +215,7 @@ class CodeEditor(Generic[TSymbol], ABC): start_col = 0 end_line_for_delete = end_line + 1 end_col = 0 - with self._edited_file_context(relative_path) as edited_file: + with self.edited_file_context(relative_path) as edited_file: start_pos = PositionInFile(line=start_line, col=start_col) end_pos = PositionInFile(line=end_line_for_delete, col=end_col) edited_file.delete_text_between_positions(start_pos, end_pos) @@ -214,7 +227,7 @@ class CodeEditor(Generic[TSymbol], ABC): symbol = self._find_unique_symbol(name_path, relative_file_path) start_pos = symbol.get_body_start_position_or_raise() end_pos = symbol.get_body_end_position_or_raise() - with self._edited_file_context(relative_file_path) as edited_file: + with self.edited_file_context(relative_file_path) as edited_file: edited_file.delete_text_between_positions(start_pos, end_pos) @abstractmethod @@ -239,21 +252,24 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): class EditedFile(CodeEditor.EditedFile): def __init__(self, lang_server: SolidLanguageServer, relative_path: str, file_buffer: LSPFileBuffer): + super().__init__(relative_path) self._lang_server = lang_server - self._relative_path = relative_path self._file_buffer = file_buffer def get_contents(self) -> str: return self._file_buffer.contents + def set_contents(self, contents: str) -> None: + self._file_buffer.contents = contents + def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: - self._lang_server.delete_text_between_positions(self._relative_path, start_pos.to_lsp_position(), end_pos.to_lsp_position()) + self._lang_server.delete_text_between_positions(self.relative_path, start_pos.to_lsp_position(), end_pos.to_lsp_position()) def insert_text_at_position(self, pos: PositionInFile, text: str) -> None: - self._lang_server.insert_text_at_position(self._relative_path, pos.line, pos.col, text) + self._lang_server.insert_text_at_position(self.relative_path, pos.line, pos.col, text) def apply_text_edits(self, text_edits: list[ls_types.TextEdit]) -> None: - return self._lang_server.apply_text_edits_to_file(self._relative_path, text_edits) + return self._lang_server.apply_text_edits_to_file(self.relative_path, text_edits) @contextmanager def _open_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: @@ -267,35 +283,70 @@ 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]: - """ - Apply a WorkspaceEdit by making the changes to files. + def _relative_path_from_uri(self, uri: str) -> str: + return os.path.relpath(PathUtils.uri_to_path(uri), self.project_root) - :param workspace_edit: The WorkspaceEdit containing the changes to apply - :return: List of relative file paths that were modified - """ - uri_to_edits = extract_text_edits(workspace_edit) - modified_relative_paths = [] + class EditOperation(ABC): + @abstractmethod + def apply(self) -> None: + pass - # Handle the 'changes' format (URI -> list of TextEdits) - for uri, edits in uri_to_edits.items(): - file_path = PathUtils.uri_to_path(uri) - relative_path = os.path.relpath(file_path, self._symbol_retriever.get_root_path()) - modified_relative_paths.append(relative_path) - with self._edited_file_context(relative_path) as edited_file: + class EditOperationFileTextEdits(EditOperation): + def __init__(self, code_editor: "LanguageServerCodeEditor", file_uri: str, text_edits: list[ls_types.TextEdit]): + self._code_editor = code_editor + self._relative_path = code_editor._relative_path_from_uri(file_uri) + self._text_edits = text_edits + + def apply(self) -> None: + with self._code_editor.edited_file_context(self._relative_path) as edited_file: edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file) - edited_file.apply_text_edits(edits) - return modified_relative_paths + edited_file.apply_text_edits(self._text_edits) + + class EditOperationRenameFile(EditOperation): + def __init__(self, code_editor: "LanguageServerCodeEditor", old_uri: str, new_uri: str): + self._code_editor = code_editor + self._old_relative_path = code_editor._relative_path_from_uri(old_uri) + self._new_relative_path = code_editor._relative_path_from_uri(new_uri) + + def apply(self) -> None: + old_abs_path = os.path.join(self._code_editor.project_root, self._old_relative_path) + new_abs_path = os.path.join(self._code_editor.project_root, self._new_relative_path) + os.rename(old_abs_path, new_abs_path) + + def _workspace_edit_to_edit_operations(self, workspace_edit: ls_types.WorkspaceEdit) -> list["LanguageServerCodeEditor.EditOperation"]: + operations: list[LanguageServerCodeEditor.EditOperation] = [] + + if "changes" in workspace_edit: + for uri, edits in workspace_edit["changes"].items(): + operations.append(self.EditOperationFileTextEdits(self, uri, edits)) + + if "documentChanges" in workspace_edit: + for change in workspace_edit["documentChanges"]: + if "textDocument" in change and "edits" in change: + operations.append(self.EditOperationFileTextEdits(self, change["textDocument"]["uri"], change["edits"])) + elif "kind" in change: + if change["kind"] == "rename": + operations.append(self.EditOperationRenameFile(self, change["oldUri"], change["newUri"])) + else: + raise ValueError(f"Unhandled document change kind: {change}; Please report to Serena developers.") + else: + raise ValueError(f"Unhandled document change format: {change}; Please report to Serena developers.") + + return operations + + def _apply_workspace_edit(self, workspace_edit: ls_types.WorkspaceEdit) -> int: + """ + Applies a WorkspaceEdit + + :param workspace_edit: the edit to apply + :return: number of edit operations applied + """ + operations = self._workspace_edit_to_edit_operations(workspace_edit) + for operation in operations: + operation.apply() + return len(operations) def rename_symbol(self, name_path: str, relative_file_path: str, new_name: str) -> str: symbol = self._find_unique_symbol(name_path, relative_file_path) @@ -315,8 +366,14 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): f"Language server for {lang_server.language_id} returned no rename edits for symbol '{name_path}'. " f"The symbol might not support renaming." ) - modified_files = self._apply_workspace_edit(rename_result) - msg = f"Successfully renamed '{name_path}' to '{new_name}' in {len(modified_files)} file(s)" + num_changes = self._apply_workspace_edit(rename_result) + + if num_changes == 0: + raise ValueError( + f"Renaming symbol '{name_path}' to '{new_name}' resulted in no changes being applied; renaming may not be supported." + ) + + msg = f"Successfully renamed '{name_path}' to '{new_name}' ({num_changes} changes applied)" return msg @@ -327,6 +384,7 @@ class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): class EditedFile(CodeEditor.EditedFile): def __init__(self, relative_path: str, project: Project): + super().__init__(relative_path) path = os.path.join(project.project_root, relative_path) log.info("Editing file: %s", path) with open(path, encoding=project.project_config.encoding) as f: @@ -335,6 +393,9 @@ class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): def get_contents(self) -> str: return self._content + def set_contents(self, contents: str) -> None: + self._content = contents + def delete_text_between_positions(self, start_pos: PositionInFile, end_pos: PositionInFile) -> None: self._content, _ = TextUtils.delete_text_between_positions( self._content, start_pos.line, start_pos.col, end_pos.line, end_pos.col @@ -347,6 +408,11 @@ class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): def _open_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]: yield self.EditedFile(relative_path, self._project) + def _save_edited_file(self, edited_file: "CodeEditor.EditedFile") -> None: + super()._save_edited_file(edited_file) + with JetBrainsPluginClient.from_project(self._project) as client: + client.refresh_file(edited_file.relative_path) + def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> JetBrainsSymbol: with JetBrainsPluginClient.from_project(self._project) as client: result = client.find_symbol(name_path, relative_path=relative_file_path, include_body=False, depth=0, include_location=True) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 7a6fc6e9..f1f34fd9 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -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) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 6cc74f84..7933c214 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -9,6 +9,7 @@ from collections.abc import Sequence from copy import deepcopy from dataclasses import dataclass, field from datetime import datetime +from enum import Enum from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar @@ -25,10 +26,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 +53,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 +86,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") @@ -108,17 +131,24 @@ 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 +class LanguageBackend(Enum): + LSP = "LSP" + """ + Use the language server protocol (LSP), spawning freely available language servers + via the SolidLSP library that is part of Serena + """ + JETBRAINS = "JetBrains" + """ + Use the Serena plugin in your JetBrains IDE. + (requires the plugin to be installed and the project being worked on to be open in your IDE) + """ + + @staticmethod + def from_str(backend_str: str) -> "LanguageBackend": + for backend in LanguageBackend: + if backend.value.lower() == backend_str.lower(): + return backend + raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}") @dataclass(kw_only=True) @@ -160,10 +190,13 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") with LogTime("Project configuration auto-generation", logger=log): + log.info("Project root: %s", project_root) project_name = project_name or project_root.name if languages is None: # determine languages automatically + log.info("Determining programming languages used in the project") language_composition = determine_programming_language_composition(str(project_root)) + log.info("Language composition: %s", language_composition) if len(language_composition) == 0: language_values = ", ".join([lang.value for lang in Language]) raise ValueError( @@ -176,15 +209,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.") @@ -192,17 +228,20 @@ 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() + log.info("Using languages: %s", languages_to_use) else: languages_to_use = [lang.value for lang in languages] config_with_comments = cls.load_commented_map(PROJECT_TEMPLATE_FILE) config_with_comments["project_name"] = project_name config_with_comments["languages"] = languages_to_use if save_to_disk: - save_yaml(cls.path_to_project_yml(project_root), config_with_comments, preserve_comments=True) + project_yml_path = cls.path_to_project_yml(project_root) + log.info("Saving project configuration to %s", project_yml_path) + save_yaml(project_yml_path, config_with_comments, preserve_comments=True) return cls._from_dict(config_with_comments) @classmethod @@ -366,6 +405,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 @@ -373,9 +413,10 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): the path to the configuration file to which updates of the configuration shall be saved; if None, the configuration is not saved to disk """ - jetbrains: bool = False + + language_backend: LanguageBackend = LanguageBackend.LSP """ - whether to apply JetBrains mode + the language backend to use for code understanding features """ token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name @@ -395,13 +436,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. @@ -416,20 +456,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": @@ -443,7 +480,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}") @@ -461,7 +498,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): # load list of known projects instance.projects = [] - num_project_migrations = 0 + num_migrations = 0 for path in loaded_commented_yaml["projects"]: path = Path(path).resolve() if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()): @@ -471,7 +508,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): path = cls._migrate_out_of_project_config_file(path) if path is None: continue - num_project_migrations += 1 + num_migrations += 1 project_config = ProjectConfig.load(path) project = RegisteredProject( project_root=str(path), @@ -479,30 +516,42 @@ 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 + def get_value_or_default(key: str, field_name: str | None = None) -> Any: + if field_name is None: + field_name = key + return loaded_commented_yaml.get(key, get_dataclass_default(SerenaConfig, field_name)) + + # determine language backend + language_backend = get_dataclass_default(SerenaConfig, "language_backend") + if "language_backend" in loaded_commented_yaml: + backend_str = loaded_commented_yaml["language_backend"] + language_backend = LanguageBackend.from_str(backend_str) else: - instance.gui_log_window_enabled = loaded_commented_yaml.get("gui_log_window", False) + # backward compatibility (migrate Boolean field "jetbrains") + if "jetbrains" in loaded_commented_yaml: + num_migrations += 1 + if loaded_commented_yaml["jetbrains"]: + language_backend = LanguageBackend.JETBRAINS + del loaded_commented_yaml["jetbrains"] + instance.language_backend = language_backend + + # set other configuration parameters (primitive types) + instance.gui_log_window_enabled = get_value_or_default("gui_log_window", "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.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: - log.info( - f"Migrated {num_project_migrations} project configurations from legacy format to in-project configuration; re-saving configuration" - ) + if num_migrations > 0: + log.info("Legacy configuration was migrated; re-saving configuration file") instance.save() return instance @@ -605,9 +654,15 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): """ if self.config_file_path is None: return + assert self.loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML" + loaded_original_yaml = deepcopy(self.loaded_commented_yaml) - # projects are unique absolute paths - # we also canonicalize them before saving + + # convert project objects into list of paths loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects}) + + # convert language backend to string + loaded_original_yaml["language_backend"] = self.language_backend.value + save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True) diff --git a/src/serena/constants.py b/src/serena/constants.py index e0ed29cf..f5ccd660 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -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") diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 6005bcad..55d8b87b 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from sensai.util import logging from serena.analytics import ToolUsageStats +from serena.config.serena_config import LanguageBackend from serena.constants import SERENA_DASHBOARD_DIR from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler @@ -443,7 +444,7 @@ class SerenaDashboardAPI: available_modes=available_modes, available_contexts=available_contexts, available_memories=available_memories, - jetbrains_mode=self._agent.serena_config.jetbrains, + jetbrains_mode=self._agent.serena_config.language_backend == LanguageBackend.JETBRAINS, languages=languages, encoding=encoding, ) @@ -560,7 +561,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 +573,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 diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index a3072286..8f3681bf 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -4,10 +4,10 @@ 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.ls_logger import LanguageServerLogger from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -21,7 +21,6 @@ class LanguageServerFactory: ignored_patterns: list[str], ls_timeout: float | None = None, ls_specific_settings: dict | None = None, - log_level: int = logging.INFO, trace_lsp_communication: bool = False, ): self.project_root = project_root @@ -29,7 +28,6 @@ class LanguageServerFactory: self.ignored_patterns = ignored_patterns self.ls_timeout = ls_timeout self.ls_specific_settings = ls_specific_settings - self.log_level = log_level self.trace_lsp_communication = trace_lsp_communication def create_language_server(self, language: Language) -> SolidLanguageServer: @@ -39,16 +37,14 @@ class LanguageServerFactory: trace_lsp_communication=self.trace_lsp_communication, encoding=self.encoding, ) - ls_logger = LanguageServerLogger(log_level=self.log_level) log.info(f"Creating language server instance for {self.project_root}, language={language}.") return SolidLanguageServer.create( ls_config, - ls_logger, self.project_root, timeout=self.ls_timeout, solidlsp_settings=SolidLSPSettings( - solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, + solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_relative_path=SERENA_MANAGED_DIR_NAME, ls_specific_settings=self.ls_specific_settings or {}, ), @@ -187,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: """ diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 6030e9a0..2335ffd1 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -13,6 +13,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 @@ -21,6 +22,7 @@ from serena.agent import ( SerenaConfig, ) from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode +from serena.config.serena_config import LanguageBackend from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, SERENA_LOG_FORMAT from serena.tools import Tool from serena.util.exception import show_fatal_exception_safe @@ -219,6 +221,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, @@ -227,7 +231,7 @@ class SerenaMCPFactory: fn_metadata=func_arg_metadata, is_async=is_async, context_kwarg=None, - annotations=None, + annotations=annotations, title=None, ) @@ -258,6 +262,7 @@ class SerenaMCPFactory: host: str = "0.0.0.0", port: int = 8000, modes: Sequence[str] = DEFAULT_MODES, + language_backend: LanguageBackend | None = None, enable_web_dashboard: bool | None = None, enable_gui_log_window: bool | None = None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, @@ -270,6 +275,7 @@ class SerenaMCPFactory: :param host: The host to bind to :param port: The port to bind to :param modes: List of mode names or paths to mode files + :param language_backend: the language backend to use, overriding the configuration setting. :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. If not specified, will take the value from the serena configuration. @@ -293,6 +299,8 @@ class SerenaMCPFactory: config.trace_lsp_communication = trace_lsp_communication if tool_timeout is not None: config.tool_timeout = tool_timeout + if language_backend is not None: + config.language_backend = language_backend modes_instances = [SerenaAgentMode.load(mode) for mode in modes] self.agent = self._create_serena_agent(config, modes_instances) diff --git a/src/serena/project.py b/src/serena/project.py index 3cd5eb08..968ad545 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -388,7 +388,6 @@ class Project(ToStringMixin): ignored_patterns=self._ignored_patterns, ls_timeout=ls_timeout, ls_specific_settings=ls_specific_settings, - log_level=log_level, trace_lsp_communication=trace_lsp_communication, ) self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory) @@ -439,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 diff --git a/src/serena/prompt_factory.py b/src/serena/prompt_factory.py index 40f51e7a..31be6f49 100644 --- a/src/serena/prompt_factory.py +++ b/src/serena/prompt_factory.py @@ -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]) diff --git a/src/serena/resources/config/contexts/agent.yml b/src/serena/resources/config/contexts/agent.yml index e2984283..a3b324f0 100644 --- a/src/serena/resources/config/contexts/agent.yml +++ b/src/serena/resources/config/contexts/agent.yml @@ -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 diff --git a/src/serena/resources/config/contexts/chatgpt.yml b/src/serena/resources/config/contexts/chatgpt.yml index 4a035516..ae1c3db6 100644 --- a/src/serena/resources/config/contexts/chatgpt.yml +++ b/src/serena/resources/config/contexts/chatgpt.yml @@ -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 diff --git a/src/serena/resources/config/contexts/claude-code.yml b/src/serena/resources/config/contexts/claude-code.yml new file mode 100644 index 00000000..118ed8b4 --- /dev/null +++ b/src/serena/resources/config/contexts/claude-code.yml @@ -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 diff --git a/src/serena/resources/config/contexts/codex.yml b/src/serena/resources/config/contexts/codex.yml index 2088ab32..a492e8f9 100644 --- a/src/serena/resources/config/contexts/codex.yml +++ b/src/serena/resources/config/contexts/codex.yml @@ -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 diff --git a/src/serena/resources/config/contexts/desktop-app.yml b/src/serena/resources/config/contexts/desktop-app.yml index c3adcfb4..8d49cec5 100644 --- a/src/serena/resources/config/contexts/desktop-app.yml +++ b/src/serena/resources/config/contexts/desktop-app.yml @@ -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 diff --git a/src/serena/resources/config/contexts/ide-assistant.yml b/src/serena/resources/config/contexts/ide-assistant.yml deleted file mode 100644 index 8d64541c..00000000 --- a/src/serena/resources/config/contexts/ide-assistant.yml +++ /dev/null @@ -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 diff --git a/src/serena/resources/config/contexts/ide.yml b/src/serena/resources/config/contexts/ide.yml new file mode 100644 index 00000000..4a917a56 --- /dev/null +++ b/src/serena/resources/config/contexts/ide.yml @@ -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 diff --git a/src/serena/resources/config/modes/editing.yml b/src/serena/resources/config/modes/editing.yml index ba468191..4de188b2 100644 --- a/src/serena/resources/config/modes/editing.yml +++ b/src/serena/resources/config/modes/editing.yml @@ -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. diff --git a/src/serena/resources/config/modes/interactive.yml b/src/serena/resources/config/modes/interactive.yml index 4da123d7..cdae4e99 100644 --- a/src/serena/resources/config/modes/interactive.yml +++ b/src/serena/resources/config/modes/interactive.yml @@ -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: [] \ No newline at end of file diff --git a/src/serena/resources/config/modes/mode.template.yml b/src/serena/resources/config/modes/mode.template.yml index 6d836ca3..d7efce0d 100644 --- a/src/serena/resources/config/modes/mode.template.yml +++ b/src/serena/resources/config/modes/mode.template.yml @@ -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: [] \ No newline at end of file diff --git a/src/serena/resources/config/modes/no-memories.yml b/src/serena/resources/config/modes/no-memories.yml new file mode 100644 index 00000000..7a522875 --- /dev/null +++ b/src/serena/resources/config/modes/no-memories.yml @@ -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 diff --git a/src/serena/resources/config/modes/no-onboarding.yml b/src/serena/resources/config/modes/no-onboarding.yml index e4d15cc7..4b893eff 100644 --- a/src/serena/resources/config/modes/no-onboarding.yml +++ b/src/serena/resources/config/modes/no-onboarding.yml @@ -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 diff --git a/src/serena/resources/config/modes/planning.yml b/src/serena/resources/config/modes/planning.yml index ce0bf2e1..a24d0dfb 100644 --- a/src/serena/resources/config/modes/planning.yml +++ b/src/serena/resources/config/modes/planning.yml @@ -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 diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index ce47c3e5..13064c14 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -3,66 +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 for more details - on how the `name_path` matches symbols. - 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 - 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` + Symbols are identified by their `name_path` and `relative_path` (see the description of the `find_symbol` tool). + 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`. 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. diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index 8795b895..1bcc7a23 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -97,11 +97,18 @@ body { } .logo-container { + height: var(--header-height); order: 1; flex-shrink: 0; } -.header-sponsor { +.logo-container img { + height: calc(var(--header-height) - 20px); + margin-top: 10px; + display: block; +} + +.header-banner { position: relative; top: 0; left: 0; @@ -109,11 +116,6 @@ body { max-height: var(--header-height); } -.logo-container img { - height: var(--header-height); - display: block; -} - .header-nav { position: relative; display: flex; @@ -178,7 +180,7 @@ body { margin: 5px 0; } -.platinum-sponsor-slide { +.platinum-banner-slide { position: absolute; top: 0; left: 0; @@ -188,26 +190,26 @@ body { transition: opacity 0.5s ease-in-out; } -.platinum-sponsor-slide.active { +.platinum-banner-slide.active { opacity: 1; pointer-events: auto; } -.sponsor-image { +.banner-image { object-fit: contain; border-radius: var(--border-radius); } -.sponsor-border { +.banner-border { border: 1px solid var(--border-color); } -.platinum-sponsor-slide .sponsor-image { +.platinum-banner-slide .banner-image { max-height: 100%; object-fit: contain; } -.gold-sponsors-section { +.gold-banners-section { margin: 0 auto; width: 100%; position: relative; @@ -223,18 +225,19 @@ body { overflow: hidden; } -.gold-sponsor-slide { +.gold-banner-slide { position: absolute; + pointer-events: none; opacity: 0; transition: opacity 0.5s ease-in-out; } -.gold-sponsor-slide.active { +.gold-banner-slide.active { opacity: 1; pointer-events: auto; } -.gold-sponsor-slide .sponsor-image { +.gold-banner-slide .banner-image { max-width: 100%; object-fit: contain; } diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index ef8168fa..63a98419 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -40,56 +40,74 @@ class LogMessage { }; } -class SponsorRotation { +function updateThemeAwareImage($img, theme=null) { + if (!theme) { + const isDarkMode = $('html').data("theme") == 'dark'; + theme = isDarkMode ? 'dark' : 'light'; + } + console.log("updating theme-aware image to theme:", theme); + const newSrc = $img.data('src-' + theme); + if (newSrc) { + $img.attr('src', newSrc); + } +} + +class BannerRotation { constructor() { this.platinumIndex = 0; this.goldIndex = 0; this.platinumTimer = null; this.goldTimer = null; - this.platinumInterval = 5000; - this.goldInterval = 5000; + this.platinumInterval = 15000; + this.goldInterval = 15000; this.init(); } init() { let self = this; - this.loadSponsors(function() { + this.loadBanners(function() { self.startPlatinumRotation(); self.startGoldRotation(); }); } - loadSponsors(onSuccess) { + loadBanners(onSuccess) { $.ajax({ - url: 'https://oraios-software.de/serena-sponsors/manifest.php', + url: 'https://oraios-software.de/serena-banners/manifest.php', type: 'GET', success: function (response) { - console.log('Sponsors loaded:', response); + console.log('Banners loaded:', response); - function fillSponsors($container, sponsors, className) { - $.each(sponsors, function (index, sponsor) { - let $img = $(''); - let $anchor = $(''); + function fillBanners($container, banners, className) { + $.each(banners, function (index, banner) { + let $img = $(''); + if (banner.image_dark) { + $img.addClass('theme-aware-img'); + $img.attr('data-src-dark', banner.image_dark); + $img.attr('data-src-light', banner.image); + updateThemeAwareImage($img); + } + let $anchor = $(''); $anchor.append($img); - let $sponsor = $('
'); - $sponsor.append($anchor); + let $banner = $('
'); + $banner.append($anchor); if (index === 0) { - $sponsor.addClass('active'); + $banner.addClass('active'); } - if (sponsor.border) { - $img.addClass('sponsor-border'); + if (banner.border) { + $img.addClass('banner-border'); } - $container.append($sponsor); + $container.append($banner); }); } - fillSponsors($('#gold-sponsors'), response.gold, 'gold-sponsor'); - fillSponsors($('#platinum-sponsors'), response.platinum, 'platinum-sponsor'); + fillBanners($('#gold-banners'), response.gold, 'gold-banner'); + fillBanners($('#platinum-banners'), response.platinum, 'platinum-banner'); onSuccess(); }, error: function (xhr, status, error) { - console.error('Error loading sponsors:', error); + console.error('Error loading banners:', error); } }); } @@ -109,7 +127,7 @@ class SponsorRotation { } rotatePlatinum(direction) { - const $slides = $('.platinum-sponsor-slide'); + const $slides = $('.platinum-banner-slide'); const total = $slides.length; if (total === 0) return; @@ -133,7 +151,7 @@ class SponsorRotation { } rotateGold(direction) { - const $groups = $('.gold-sponsor-slide'); + const $groups = $('.gold-banner-slide'); const total = $groups.length; if (total === 0) return; @@ -360,8 +378,8 @@ class Dashboard { // Initialize theme this.initializeTheme(); - // Initialize sponsor rotation - //this.sponsorRotation = new SponsorRotation(); + // Initialize banner rotation + this.bannerRotation = new BannerRotation(); // Add ESC key handler for closing modals $(document).keydown(function (e) { @@ -1463,6 +1481,9 @@ class Dashboard { this.setTheme(newTheme); } + /** + * @param theme {'light' | 'dark'} + */ setTheme(theme) { // Set the theme on the document element document.documentElement.setAttribute('data-theme', theme); @@ -1476,8 +1497,11 @@ class Dashboard { this.$themeText.text('Dark'); } - // Update the logo based on theme - this.updateLogo(theme); + // Update theme-aware images + $(".theme-aware-img").each(function() { + const $img = $(this); + updateThemeAwareImage($img, theme); + }); // Save to localStorage localStorage.setItem('serena-theme', theme); @@ -1486,17 +1510,6 @@ class Dashboard { this.updateChartsTheme(); } - updateLogo(theme) { - const logoElement = document.getElementById('serena-logo'); - if (logoElement) { - if (theme === 'dark') { - logoElement.src = 'serena-logo-dark-mode.svg'; - } else { - logoElement.src = 'serena-logo.svg'; - } - } - } - updateChartsTheme() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const textColor = isDark ? '#ffffff' : '#000000'; diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index 8937051e..a8349e14 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -21,11 +21,11 @@
- +
- -
+ +
@@ -134,8 +134,8 @@
- -
+ +
diff --git a/src/serena/resources/dashboard/serena-icon-32.png b/src/serena/resources/dashboard/serena-icon-32.png index 348a9224..1596e470 100644 Binary files a/src/serena/resources/dashboard/serena-icon-32.png and b/src/serena/resources/dashboard/serena-icon-32.png differ diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 6a6ae491..d197991d 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -1,3 +1,12 @@ +language_backend: LSP +# the language backend to use for code understanding and manipulation. +# Possible values are: +# * LSP: Use the language server protocol (LSP), spawning freely available language servers +# via the SolidLSP library that is part of Serena. +# * JetBrains: Use the Serena plugin in your JetBrains IDE. +# (requires the plugin to be installed and the project being worked on to be open +# in your IDE). + gui_log_window: False # whether to open a graphical window with Serena's logs. # This is mainly supported on Windows and (partly) on Linux; not available on macOS. @@ -20,6 +29,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 @@ -39,7 +51,6 @@ ls_specific_settings: {} # (same entry as in project.yml) to the options. # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. -# tool_timeout: 240 # timeout, in seconds, after which tool executions are terminated @@ -50,12 +61,6 @@ excluded_tools: [] included_optional_tools: [] # list of optional tools (which are disabled by default) to be included -jetbrains: False -# whether to enable JetBrains mode and use tools based on the Serena JetBrains IDE plugin -# instead of language server-based tools -# NOTE: The plugin is yet unreleased. This is for Serena developers only. - - default_max_tool_answer_chars: 150000 # Used as default for tools where the apply method has a default maximal answer length. # Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default @@ -70,14 +75,5 @@ token_count_estimator: CHAR_COUNT # data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to # estimate the token count using the Claude Sonnet 4 tokenizer. - -# MANAGED BY SERENA, KEEP AT THE BOTTOM OF THE YAML AND DON'T EDIT WITHOUT NEED -# The list of registered projects. -# To add a project, within a chat, simply ask Serena to "activate the project /path/to/project" or, -# if the project was previously added, "activate the project ". -# By default, the project's name will be the name of the directory containing the project, but you may change it -# by editing the (auto-generated) project configuration file `/path/project/project/.serena/project.yml` file. -# If you want to maintain full control of the project configuration, create the project.yml file manually and then -# instruct Serena to activate the project by its path for first-time activation. -# NOTE: Make sure there are no name collisions in the names of registered projects. +# The list of registered project paths (updated automatically). projects: [] diff --git a/src/serena/symbol.py b/src/serena/symbol.py index e3753041..ecc13353 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -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 diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index bc856ec5..45ca01c7 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -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): diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 69e5c140..d243cd31 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -6,10 +6,10 @@ File and file system-related tools, specifically for * editing at the file level """ -import json import os import re from collections import defaultdict +from collections.abc import Callable from fnmatch import fnmatch from pathlib import Path from typing import Literal @@ -79,7 +79,7 @@ class CreateTextFileTool(Tool, ToolMarkerCanEdit): answer = f"File created: {relative_path}." if will_overwrite_existing: answer += " Overwrote existing file." - return json.dumps(answer) + return answer class ListDirTool(Tool): @@ -106,7 +106,7 @@ class ListDirTool(Tool): "project_root": self.get_project_root(), "hint": "Check if the path is correct relative to the project root", } - return json.dumps(error_info) + return self._to_json(error_info) self.project.validate_relative_path(relative_path, require_not_ignored=skip_ignored_files) @@ -118,7 +118,7 @@ class ListDirTool(Tool): is_ignored_file=self.project.is_ignored_path if skip_ignored_files else None, ) - result = json.dumps({"dirs": dirs, "files": files}) + result = self._to_json({"dirs": dirs, "files": files}) return self._limit_length(result, max_answer_chars) @@ -154,7 +154,7 @@ class FindFileTool(Tool): relative_to=self.get_project_root(), ) - result = json.dumps({"files": files}) + result = self._to_json({"files": files}) return result @@ -203,6 +203,45 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True ) + @staticmethod + def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]: + """ + Creates a replacement function that validates for ambiguity and handles backreferences. + + :param regex_pattern: The regex pattern being used for matching + :param repl_template: The replacement template with $!1, $!2, etc. for backreferences + :param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE) + :return: A function suitable for use with re.sub() or re.subn() + """ + + def validate_and_replace(match: re.Match) -> str: + matched_text = match.group(0) + + # For multi-line match, check if the same pattern matches again within the already-matched text, + # rendering the match ambiguous. Typical pattern in the code: + # + # When matching + # .*? + # this will match the entire span above, while only the suffix may have been intended. + # (See test case for a practical example.) + # To detect this, we check if the same pattern matches again within the matched text, + if "\n" in matched_text and re.search(regex_pattern, matched_text[1:], flags=regex_flags): + raise ValueError( + "Match is ambiguous: the search pattern matches multiple overlapping occurrences. " + "Please revise the search pattern to be more specific to avoid ambiguity." + ) + + # Handle backreferences: replace $!1, $!2, etc. with actual matched groups + def expand_backreference(m: re.Match) -> str: + group_num = int(m.group(1)) + group_value = match.group(group_num) + return group_value if group_value is not None else m.group(0) + + result = re.sub(r"\$!(\d+)", expand_backreference, repl_template) + return result + + return validate_and_replace + def replace_content( self, relative_path: str, @@ -217,7 +256,7 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): This function can be used internally by other tools. """ self.project.validate_relative_path(relative_path, require_not_ignored=require_not_ignored) - with EditedFileContext(relative_path, self.agent) as context: + with EditedFileContext(relative_path, self.create_code_editor()) as context: original_content = context.get_original_content() if mode == "literal": @@ -227,19 +266,19 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): else: raise ValueError(f"Invalid mode: '{mode}', expected 'literal' or 'regex'.") - # escape backslashes in repl - repl = repl.replace("\\", "\\\\") - # convert $!1, $!2, etc. to \1, \2, etc. for backreferences - repl = re.sub(r"\$!(\d+)", "\\\\1", repl) + regex_flags = re.DOTALL | re.MULTILINE + + # create replacement function with validation and backreference handling + repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags) # perform replacement - updated_content, n = re.subn(regex, repl, original_content, flags=re.DOTALL | re.MULTILINE) + updated_content, n = re.subn(regex, repl_fn, original_content, flags=regex_flags) if n == 0: - return f"Error: No matches of search expression found in file '{relative_path}'." + raise ValueError(f"Error: No matches of search expression found in file '{relative_path}'.") if not allow_multiple_occurrences and n > 1: - return ( - f"Error: Expression matches {n} occurrences in file '{relative_path}'. " + raise ValueError( + f"Expression matches {n} occurrences in file '{relative_path}'. " "Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected." ) context.set_updated_content(updated_content) @@ -440,5 +479,5 @@ class SearchForPatternTool(Tool): for match in matches: assert match.source_file_path is not None file_to_matches[match.source_file_path].append(match.to_display_string()) - result = json.dumps(file_to_matches) + result = self._to_json(file_to_matches) return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 47efb30a..f889e725 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -41,9 +41,13 @@ class JetBrainsPluginClient(ToStringMixin): """ BASE_PORT = 0x5EA2 + PLUGIN_REQUEST_TIMEOUT = 300 + """ + the timeout used for request handling within the plugin (a constant in the plugin) + """ last_port: int | None = None - def __init__(self, port: int, timeout: int = 30): + def __init__(self, port: int, timeout: int = PLUGIN_REQUEST_TIMEOUT): self.base_url = f"http://127.0.0.1:{port}" self.timeout = timeout self.session = requests.Session() @@ -133,7 +137,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 +152,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 +163,7 @@ class JetBrainsPluginClient(ToStringMixin): "includeBody": include_body, "depth": depth, "includeLocation": include_location, + "searchDeps": search_deps, } return self._make_request("POST", "/findSymbol", request_data) @@ -193,6 +206,17 @@ class JetBrainsPluginClient(ToStringMixin): } self._make_request("POST", "/renameSymbol", request_data) + def refresh_file(self, relative_path: str) -> None: + """ + Triggers a refresh of the given file in the IDE. + + :param relative_path: the relative path + """ + request_data = { + "relativePath": relative_path, + } + self._make_request("POST", "/refreshFile", request_data) + def is_service_available(self) -> bool: try: self.project_root() diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 97cfd19f..e2b84e6e 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -1,5 +1,3 @@ -import json - from serena.tools import Tool, ToolMarkerOptional, ToolMarkerSymbolicRead from serena.tools.jetbrains_plugin_client import JetBrainsPluginClient @@ -15,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: """ @@ -44,18 +43,22 @@ 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. """ + if relative_path == ".": + relative_path = None with JetBrainsPluginClient.from_project(self.project) as client: response_dict = client.find_symbol( name_path=name_path_pattern, relative_path=relative_path, depth=depth, include_body=include_body, + search_deps=search_deps, ) - result = json.dumps(response_dict) + result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) @@ -86,7 +89,7 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark name_path=name_path, relative_path=relative_path, ) - result = json.dumps(response_dict) + result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) @@ -116,5 +119,5 @@ class JetBrainsGetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOp response_dict = client.get_symbols_overview( relative_path=relative_path, ) - result = json.dumps(response_dict) + result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/memory_tools.py b/src/serena/tools/memory_tools.py index cc940257..94b6900f 100644 --- a/src/serena/tools/memory_tools.py +++ b/src/serena/tools/memory_tools.py @@ -1,4 +1,3 @@ -import json from typing import Literal from serena.tools import ReplaceContentTool, Tool @@ -50,7 +49,7 @@ class ListMemoriesTool(Tool): """ List available memories. Any memory can be read using the `read_memory` tool. """ - return json.dumps(self.memories_manager.list_memories()) + return self._to_json(self.memories_manager.list_memories()) class DeleteMemoryTool(Tool): diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 62a4fd9e..75d0e5c9 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -2,8 +2,6 @@ Language server-related tools """ -import dataclasses -import json import os from collections.abc import Sequence from copy import copy @@ -51,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. @@ -72,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 = json.dumps([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) @@ -137,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, @@ -145,7 +145,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): within_relative_path=relative_path, ) symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols] - result = json.dumps(symbol_dicts) + result = self._to_json(symbol_dicts) return self._limit_length(result, max_answer_chars) @@ -198,7 +198,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): ) ref_dict["content_around_reference"] = content_around_ref.to_display_string() reference_dicts.append(ref_dict) - result = json.dumps(reference_dicts) + result = self._to_json(reference_dicts) return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index cb163357..16f3f517 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -1,5 +1,5 @@ import inspect -import os +import json from abc import ABC from collections.abc import Iterable from dataclasses import dataclass @@ -295,6 +295,10 @@ class Tool(Component): log.error(msg) return msg + @staticmethod + def _to_json(x: Any) -> str: + return json.dumps(x, ensure_ascii=False) + class EditedFileContext: """ @@ -305,24 +309,23 @@ class EditedFileContext: When exiting the context without an exception, the updated content will be written back to the file. """ - def __init__(self, relative_path: str, agent: "SerenaAgent"): - self._project = agent.get_active_project() - assert self._project is not None - self._abs_path = os.path.join(self._project.project_root, relative_path) - if not os.path.isfile(self._abs_path): - raise FileNotFoundError(f"File {self._abs_path} does not exist.") - with open(self._abs_path, encoding=self._project.project_config.encoding) as f: - self._original_content = f.read() - self._updated_content: str | None = None + def __init__(self, relative_path: str, code_editor: "CodeEditor"): + self._relative_path = relative_path + self._code_editor = code_editor + self._edited_file: CodeEditor.EditedFile | None = None + self._edited_file_context: Any = None def __enter__(self) -> Self: + self._edited_file_context = self._code_editor.edited_file_context(self._relative_path) + self._edited_file = self._edited_file_context.__enter__() return self def get_original_content(self) -> str: """ :return: the original content of the file before any modifications. """ - return self._original_content + assert self._edited_file is not None + return self._edited_file.get_contents() def set_updated_content(self, content: str) -> None: """ @@ -331,16 +334,12 @@ class EditedFileContext: :param content: the updated content of the file """ - self._updated_content = content + assert self._edited_file is not None + self._edited_file.set_contents(content) def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None: - if self._updated_content is not None and exc_type is None: - assert self._project is not None - with open(self._abs_path, "w", encoding=self._project.project_config.encoding) as f: - f.write(self._updated_content) - log.info(f"Updated content written to {self._abs_path}") - # Language servers should automatically detect the change and update its state accordingly. - # If they do not, we may have to add a call to notify it. + assert self._edited_file_context is not None + self._edited_file_context.__exit__(exc_type, exc_value, traceback) @dataclass(kw_only=True) diff --git a/src/serena/tools/workflow_tools.py b/src/serena/tools/workflow_tools.py index 5961a397..a3a58104 100644 --- a/src/serena/tools/workflow_tools.py +++ b/src/serena/tools/workflow_tools.py @@ -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() diff --git a/src/serena/util/general.py b/src/serena/util/general.py index 4a1397e2..f380c699 100644 --- a/src/serena/util/general.py +++ b/src/serena/util/general.py @@ -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") diff --git a/src/serena/util/gui.py b/src/serena/util/gui.py new file mode 100644 index 00000000..f87236d4 --- /dev/null +++ b/src/serena/util/gui.py @@ -0,0 +1,22 @@ +import os +import platform + + +def system_has_usable_display() -> bool: + system = platform.system() + + # macOS and native Windows: assume display is available for desktop usage + if system == "Darwin" or system == "Windows": + return True + + # Other systems, assumed to be Unix-like (Linux, FreeBSD, Cygwin/MSYS, etc.): + # detect display availability since users may operate in CLI contexts + else: + # Check X11 or Wayland - if environment variables are set to non-empty values, assume display is usable + display = os.environ.get("DISPLAY", "") + wayland_display = os.environ.get("WAYLAND_DISPLAY", "") + + if display or wayland_display: + return True + + return False diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py index 668cf545..c67cc1f3 100644 --- a/src/serena/util/inspection.py +++ b/src/serena/util/inspection.py @@ -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 diff --git a/src/serena/util/logging.py b/src/serena/util/logging.py index 75af996b..dae83c0a 100644 --- a/src/serena/util/logging.py +++ b/src/serena/util/logging.py @@ -1,11 +1,14 @@ import queue import threading from collections.abc import Callable +from typing import Optional from sensai.util import logging from serena.constants import SERENA_LOG_FORMAT +lg = logging + class MemoryLogHandler(logging.Handler): def __init__(self, level: int = logging.NOTSET) -> None: @@ -65,3 +68,42 @@ class LogBuffer: def get_log_messages(self) -> list[str]: with self._lock: return self._log_messages.copy() + + +class SuspendedLoggersContext: + """A context manager that provides an isolated logging environment. + + Temporarily removes all root log handlers upon entry, providing a clean slate + for defining new log handlers within the context. Upon exit, restores the original + logging configuration. This is useful when you need to temporarily configure + an isolated logging setup with well-defined log handlers. + + The context manager: + - Removes all existing (root) log handlers on entry + - Allows defining new temporary handlers within the context + - Restores the original configuration (handlers and root log level) on exit + + Example: + >>> with SuspendedLoggersContext(): + ... # No handlers are active here (configure your own and set desired log level) + ... pass + >>> # Original log handlers are restored here + + """ + + def __init__(self) -> None: + self.saved_root_handlers: list = [] + self.saved_root_level: Optional[int] = None + + def __enter__(self) -> "SuspendedLoggersContext": + root_logger = lg.getLogger() + self.saved_root_handlers = root_logger.handlers.copy() + self.saved_root_level = root_logger.level + root_logger.handlers.clear() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore + root_logger = lg.getLogger() + root_logger.handlers = self.saved_root_handlers + if self.saved_root_level is not None: + root_logger.setLevel(self.saved_root_level) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index b98551bd..d6052a2a 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -15,12 +15,13 @@ from overrides import override from solidlsp.language_servers.common import quote_windows_path from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class ALLanguageServer(SolidLanguageServer): """ @@ -37,9 +38,7 @@ class ALLanguageServer(SolidLanguageServer): - File opening requirement before symbol retrieval """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Initialize the AL Language Server. @@ -58,7 +57,7 @@ class ALLanguageServer(SolidLanguageServer): """ # Setup runtime dependencies and get the language server command # This will download the AL extension if needed - cmd = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + cmd = self._setup_runtime_dependencies(config, solidlsp_settings) self._project_load_check_supported: bool = True """Whether the AL server supports the project load status check request. @@ -68,17 +67,10 @@ class ALLanguageServer(SolidLanguageServer): request fails, preventing repeated unsuccessful attempts. """ - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), - "al", # Language ID for LSP - solidlsp_settings, - ) + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "al", solidlsp_settings) @classmethod - def _download_al_extension(cls, logger: LanguageServerLogger, url: str, target_dir: str) -> bool: + def _download_al_extension(cls, url: str, target_dir: str) -> bool: """ Download and extract the AL extension from VS Code marketplace. @@ -99,7 +91,7 @@ class ALLanguageServer(SolidLanguageServer): """ try: - logger.log(f"Downloading AL extension from {url}", logging.INFO) + log.info(f"Downloading AL extension from {url}") # Create target directory for the extension os.makedirs(target_dir, exist_ok=True) @@ -118,7 +110,7 @@ class ALLanguageServer(SolidLanguageServer): temp_file = os.path.join(target_dir, "al_extension_temp.vsix") total_size = int(response.headers.get("content-length", 0)) - logger.log(f"Downloading {total_size / 1024 / 1024:.1f} MB...", logging.INFO) + log.info(f"Downloading {total_size / 1024 / 1024:.1f} MB...") with open(temp_file, "wb") as f: downloaded = 0 @@ -128,9 +120,9 @@ class ALLanguageServer(SolidLanguageServer): downloaded += len(chunk) if total_size > 0 and downloaded % (10 * 1024 * 1024) == 0: # Log progress every 10MB progress = (downloaded / total_size) * 100 - logger.log(f"Download progress: {progress:.1f}%", logging.INFO) + log.info(f"Download progress: {progress:.1f}%") - logger.log("Download complete, extracting...", logging.INFO) + log.info("Download complete, extracting...") # Extract VSIX file (VSIX files are just ZIP archives with a different extension) # This will extract the extension folder containing the language server binaries @@ -140,17 +132,15 @@ class ALLanguageServer(SolidLanguageServer): # Clean up temp file os.remove(temp_file) - logger.log("AL extension extracted successfully", logging.INFO) + log.info("AL extension extracted successfully") return True except Exception as e: - logger.log(f"Error downloading/extracting AL extension: {e}", logging.ERROR) + log.error(f"Error downloading/extracting AL extension: {e}") return False @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> str: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for AL Language Server and return the command to start the server. @@ -168,10 +158,10 @@ class ALLanguageServer(SolidLanguageServer): system = platform.system() # Find existing extension or download if needed - extension_path = cls._find_al_extension(logger, solidlsp_settings) + extension_path = cls._find_al_extension(solidlsp_settings) if extension_path is None: - logger.log("AL extension not found on disk, attempting to download...", logging.INFO) - extension_path = cls._download_and_install_al_extension(logger, solidlsp_settings) + log.info("AL extension not found on disk, attempting to download...") + extension_path = cls._download_and_install_al_extension(solidlsp_settings) if extension_path is None: raise RuntimeError( @@ -188,10 +178,10 @@ class ALLanguageServer(SolidLanguageServer): raise RuntimeError(f"AL Language Server executable not found at: {executable_path}") # Prepare and return the executable command - return cls._prepare_executable(executable_path, system, logger) + return cls._prepare_executable(executable_path, system) @classmethod - def _find_al_extension(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> str | None: + def _find_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: """ Find AL extension in various locations. @@ -207,28 +197,28 @@ class ALLanguageServer(SolidLanguageServer): # Check environment variable env_path = os.environ.get("AL_EXTENSION_PATH") if env_path and os.path.exists(env_path): - logger.log(f"Found AL extension via AL_EXTENSION_PATH: {env_path}", logging.DEBUG) + log.debug(f"Found AL extension via AL_EXTENSION_PATH: {env_path}") return env_path elif env_path: - logger.log(f"AL_EXTENSION_PATH set but directory not found: {env_path}", logging.WARNING) + log.warning(f"AL_EXTENSION_PATH set but directory not found: {env_path}") # Check default download location default_path = os.path.join(cls.ls_resources_dir(solidlsp_settings), "al-extension", "extension") if os.path.exists(default_path): - logger.log(f"Found AL extension in default location: {default_path}", logging.DEBUG) + log.debug(f"Found AL extension in default location: {default_path}") return default_path # Search VS Code extensions - vscode_path = cls._find_al_extension_in_vscode(logger) + vscode_path = cls._find_al_extension_in_vscode() if vscode_path: - logger.log(f"Found AL extension in VS Code: {vscode_path}", logging.DEBUG) + log.debug(f"Found AL extension in VS Code: {vscode_path}") return vscode_path - logger.log("AL extension not found in any known location", logging.DEBUG) + log.debug("AL extension not found in any known location") return None @classmethod - def _download_and_install_al_extension(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> str | None: + def _download_and_install_al_extension(cls, solidlsp_settings: SolidLSPSettings) -> str | None: """ Download and install AL extension from VS Code marketplace. @@ -242,17 +232,17 @@ class ALLanguageServer(SolidLanguageServer): AL_VERSION = "latest" url = f"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-dynamics-smb/vsextensions/al/{AL_VERSION}/vspackage" - logger.log(f"Downloading AL extension from: {url}", logging.INFO) + log.info(f"Downloading AL extension from: {url}") - if cls._download_al_extension(logger, url, al_extension_dir): + if cls._download_al_extension(url, al_extension_dir): extension_path = os.path.join(al_extension_dir, "extension") if os.path.exists(extension_path): - logger.log("AL extension downloaded and installed successfully", logging.INFO) + log.info("AL extension downloaded and installed successfully") return extension_path else: - logger.log(f"Download completed but extension not found at: {extension_path}", logging.ERROR) + log.error(f"Download completed but extension not found at: {extension_path}") else: - logger.log("Failed to download AL extension from marketplace", logging.ERROR) + log.error("Failed to download AL extension from marketplace") return None @@ -279,7 +269,7 @@ class ALLanguageServer(SolidLanguageServer): raise RuntimeError(f"Unsupported platform: {system}") @classmethod - def _prepare_executable(cls, executable_path: str, system: str, logger: LanguageServerLogger) -> str: + def _prepare_executable(cls, executable_path: str, system: str) -> str: """ Prepare the executable by setting permissions and handling path quoting. @@ -296,16 +286,16 @@ class ALLanguageServer(SolidLanguageServer): if system in ["Linux", "Darwin"]: st = os.stat(executable_path) os.chmod(executable_path, st.st_mode | stat.S_IEXEC) - logger.log(f"Set execute permission on: {executable_path}", logging.DEBUG) + log.debug(f"Set execute permission on: {executable_path}") - logger.log(f"Using AL Language Server executable: {executable_path}", logging.INFO) + log.info(f"Using AL Language Server executable: {executable_path}") # The AL Language Server uses stdio for LSP communication by default # Use the utility function to handle Windows path quoting return quote_windows_path(executable_path) @classmethod - def _get_language_server_command_fallback(cls, logger: LanguageServerLogger) -> str: + def _get_language_server_command_fallback(cls) -> str: """ Get the command to start the AL language server. @@ -333,10 +323,10 @@ class ALLanguageServer(SolidLanguageServer): if potential_extension: al_extension_path = str(potential_extension) - logger.log(f"Found AL extension in current directory: {al_extension_path}", logging.DEBUG) + log.debug(f"Found AL extension in current directory: {al_extension_path}") else: # Try to find in common VS Code extension locations - al_extension_path = cls._find_al_extension_in_vscode(logger) + al_extension_path = cls._find_al_extension_in_vscode() if not al_extension_path: raise RuntimeError( @@ -368,14 +358,14 @@ class ALLanguageServer(SolidLanguageServer): st = os.stat(executable) os.chmod(executable, st.st_mode | stat.S_IEXEC) - logger.log(f"Using AL Language Server executable: {executable}", logging.INFO) + log.info(f"Using AL Language Server executable: {executable}") # The AL Language Server uses stdio for LSP communication (no --stdio flag needed) # Use the utility function to handle Windows path quoting return quote_windows_path(executable) @classmethod - def _find_al_extension_in_vscode(cls, logger: LanguageServerLogger) -> str | None: + def _find_al_extension_in_vscode(cls) -> str | None: """ Try to find AL extension in common VS Code extension locations. @@ -407,11 +397,11 @@ class ALLanguageServer(SolidLanguageServer): for base_path in possible_paths: if base_path.exists(): - logger.log(f"Searching for AL extension in: {base_path}", logging.DEBUG) + log.debug(f"Searching for AL extension in: {base_path}") # Look for AL extension directories for item in base_path.iterdir(): if item.is_dir() and item.name.startswith("ms-dynamics-smb.al-"): - logger.log(f"Found AL extension at: {item}", logging.DEBUG) + log.debug(f"Found AL extension at: {item}") return str(item) return None @@ -499,18 +489,18 @@ class ALLanguageServer(SolidLanguageServer): def do_nothing(params: str) -> None: return - def window_log_message(msg: str) -> None: - self.logger.log(f"AL LSP: window/logMessage: {msg}", logging.INFO) + def window_log_message(msg: dict) -> None: + log.info(f"AL LSP: window/logMessage: {msg}") def publish_diagnostics(params: dict) -> None: # AL server publishes diagnostics during initialization uri = params.get("uri", "") diagnostics = params.get("diagnostics", []) - self.logger.log(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues", logging.DEBUG) + log.debug(f"AL LSP: Diagnostics for {uri}: {len(diagnostics)} issues") def handle_al_notifications(params: dict) -> None: # AL server sends custom notifications during project loading - self.logger.log("AL LSP: Notification received", logging.DEBUG) + log.debug("AL LSP: Notification received") # Register handlers for AL-specific notifications # These notifications are sent by the AL server during initialization and operation @@ -520,27 +510,24 @@ class ALLanguageServer(SolidLanguageServer): self.server.on_notification("al/refreshExplorerObjects", handle_al_notifications) # AL-specific object updates # Start the server process - self.logger.log("Starting AL Language Server process", logging.INFO) + log.info("Starting AL Language Server process") self.server.start() # Send initialize request initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to AL LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to AL LSP server and awaiting response") # Send initialize and wait for response resp = self.server.send_request("initialize", initialize_params) if resp is None: raise RuntimeError("AL Language Server initialization failed - no response") - self.logger.log("AL Language Server initialized successfully", logging.INFO) + log.info("AL Language Server initialized successfully") # Send initialized notification self.server.send_notification("initialized", {}) - self.logger.log("Sent initialized notification", logging.INFO) + log.info("Sent initialized notification") @override def start(self) -> "ALLanguageServer": @@ -598,9 +585,9 @@ class ALLanguageServer(SolidLanguageServer): } }, ) - self.logger.log("Sent workspace configuration", logging.DEBUG) + log.debug("Sent workspace configuration") except Exception as e: - self.logger.log(f"Failed to send workspace config: {e}", logging.WARNING) + log.warning(f"Failed to send workspace config: {e}") # Check if app.json exists and open it # app.json is the AL project manifest file (similar to package.json for Node.js) @@ -620,9 +607,9 @@ class ALLanguageServer(SolidLanguageServer): {"textDocument": {"uri": app_json_uri, "languageId": "json", "version": 1, "text": app_json_content}}, ) - self.logger.log(f"Opened app.json: {app_json_uri}", logging.DEBUG) + log.debug(f"Opened app.json: {app_json_uri}") except Exception as e: - self.logger.log(f"Failed to open app.json: {e}", logging.WARNING) + log.warning(f"Failed to open app.json: {e}") # Try to set active workspace (AL-specific custom LSP request) # This is optional and may not be supported by all AL server versions @@ -639,10 +626,10 @@ class ALLanguageServer(SolidLanguageServer): "timeout": 2, # Quick timeout since this is optional }, ) - self.logger.log(f"Set active workspace result: {result}", logging.DEBUG) + log.debug(f"Set active workspace result: {result}") except Exception as e: # This is a custom AL request, not critical if it fails - self.logger.log(f"Failed to set active workspace (non-critical): {e}", logging.DEBUG) + log.debug(f"Failed to set active workspace (non-critical): {e}") # Check if project supports load status check (optional) # Many AL server versions don't support this, so we use a short timeout @@ -704,7 +691,7 @@ class ALLanguageServer(SolidLanguageServer): Full symbol tree with all AL symbols from opened files organized by directory """ - self.logger.log("AL: Starting request_full_symbol_tree with file opening", logging.DEBUG) + log.debug("AL: Starting request_full_symbol_tree with file opening") # Determine the root path for scanning if within_relative_path is not None: @@ -743,10 +730,10 @@ class ALLanguageServer(SolidLanguageServer): # File is outside repository root, skip it continue - self.logger.log(f"AL: Found {len(al_files)} AL files", logging.DEBUG) + log.debug(f"AL: Found {len(al_files)} AL files") if not al_files: - self.logger.log("AL: No AL files found in repository", logging.WARNING) + log.warning("AL: No AL files found in repository") return [] # Collect all symbols from all files @@ -756,7 +743,7 @@ class ALLanguageServer(SolidLanguageServer): for file_path, relative_path in al_files: try: # Use our overridden request_document_symbols which handles opening - self.logger.log(f"AL: Getting symbols for {relative_path}", logging.DEBUG) + log.debug(f"AL: Getting symbols for {relative_path}") all_syms, root_syms = self.request_document_symbols(relative_path).get_all_symbols_and_roots() if root_syms: @@ -773,7 +760,7 @@ class ALLanguageServer(SolidLanguageServer): }, } all_file_symbols.append(file_symbol) - self.logger.log(f"AL: Added {len(root_syms)} symbols from {relative_path}", logging.DEBUG) + log.debug(f"AL: Added {len(root_syms)} symbols from {relative_path}") elif all_syms: # If we only got all_syms but not root, use all_syms file_symbol = { @@ -788,13 +775,13 @@ class ALLanguageServer(SolidLanguageServer): }, } all_file_symbols.append(file_symbol) - self.logger.log(f"AL: Added {len(all_syms)} symbols from {relative_path}", logging.DEBUG) + log.debug(f"AL: Added {len(all_syms)} symbols from {relative_path}") except Exception as e: - self.logger.log(f"AL: Failed to get symbols for {relative_path}: {e}", logging.WARNING) + log.warning(f"AL: Failed to get symbols for {relative_path}: {e}") if all_file_symbols: - self.logger.log(f"AL: Returning symbols from {len(all_file_symbols)} files", logging.DEBUG) + log.debug(f"AL: Returning symbols from {len(all_file_symbols)} files") # Group files by directory directory_structure: dict[str, list] = {} @@ -839,7 +826,7 @@ class ALLanguageServer(SolidLanguageServer): return result else: - self.logger.log("AL: No symbols found in any files", logging.WARNING) + log.warning("AL: No symbols found in any files") return [] # ===== Phase 1: Custom AL Command Implementations ===== @@ -862,10 +849,10 @@ class ALLanguageServer(SolidLanguageServer): try: # Use custom AL command instead of standard LSP response = self.server.send_request("al/gotodefinition", al_params) - self.logger.log(f"AL gotodefinition response: {response}", logging.DEBUG) + log.debug(f"AL gotodefinition response: {response}") return response # type: ignore[return-value] except Exception as e: - self.logger.log(f"Failed to use al/gotodefinition, falling back to standard: {e}", logging.WARNING) + log.warning(f"Failed to use al/gotodefinition, falling back to standard: {e}") # Fallback to standard LSP method if custom command fails return super()._send_definition_request(definition_params) @@ -883,7 +870,7 @@ class ALLanguageServer(SolidLanguageServer): """ if not hasattr(self, "server") or not self.server_started: - self.logger.log("Cannot check project load - server not started", logging.DEBUG) + log.debug("Cannot check project load - server not started") return False # Check if we've already determined this request isn't supported @@ -900,15 +887,15 @@ class ALLanguageServer(SolidLanguageServer): return response.get("loaded", False) elif response is None: # None typically means the project is still loading - self.logger.log("Project load check returned None", logging.DEBUG) + log.debug("Project load check returned None") return False else: - self.logger.log(f"Unexpected response type for project load check: {type(response)}", logging.DEBUG) + log.debug(f"Unexpected response type for project load check: {type(response)}") return False except Exception as e: # Mark as unsupported to avoid repeated failed attempts self._project_load_check_supported = False - self.logger.log(f"Project load check not supported by this AL server version: {e}", logging.DEBUG) + log.debug(f"Project load check not supported by this AL server version: {e}") # Assume loaded if we can't check return True @@ -928,16 +915,16 @@ class ALLanguageServer(SolidLanguageServer): """ start_time = time.time() - self.logger.log(f"Checking AL project load status (timeout: {timeout}s)...", logging.DEBUG) + log.debug(f"Checking AL project load status (timeout: {timeout}s)...") while time.time() - start_time < timeout: if self.check_project_loaded(): elapsed = time.time() - start_time - self.logger.log(f"AL project fully loaded after {elapsed:.1f}s", logging.INFO) + log.info(f"AL project fully loaded after {elapsed:.1f}s") return True time.sleep(0.5) - self.logger.log(f"Project load check timed out after {timeout}s (non-critical)", logging.DEBUG) + log.debug(f"Project load check timed out after {timeout}s (non-critical)") return False def set_active_workspace(self, workspace_uri: str | None = None) -> None: @@ -956,7 +943,7 @@ class ALLanguageServer(SolidLanguageServer): """ if not hasattr(self, "server") or not self.server_started: - self.logger.log("Cannot set active workspace - server not started", logging.DEBUG) + log.debug("Cannot set active workspace - server not started") return if workspace_uri is None: @@ -966,7 +953,7 @@ class ALLanguageServer(SolidLanguageServer): try: self.server.send_request("al/setActiveWorkspace", params) - self.logger.log(f"Set active workspace to: {workspace_uri}", logging.INFO) + log.info(f"Set active workspace to: {workspace_uri}") except Exception as e: - self.logger.log(f"Failed to set active workspace: {e}", logging.WARNING) + log.warning(f"Failed to set active workspace: {e}") # Non-critical error, continue operation diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index 6759b5a6..870e08b6 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -12,11 +12,12 @@ import threading from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class BashLanguageServer(SolidLanguageServer): """ @@ -24,17 +25,14 @@ class BashLanguageServer(SolidLanguageServer): Contains various configurations and settings specific to Bash scripting. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a BashLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - bash_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + bash_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=bash_lsp_executable_path, cwd=repository_root_path), "bash", @@ -44,9 +42,7 @@ class BashLanguageServer(SolidLanguageServer): self.initialize_searcher_command_available = threading.Event() @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> str: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for Bash Language Server and return the command to start the server. """ @@ -76,9 +72,9 @@ class BashLanguageServer(SolidLanguageServer): bash_executable_path += ".cmd" if not os.path.exists(bash_executable_path): - logger.log(f"Bash Language Server executable not found at {bash_executable_path}. Installing...", logging.INFO) - deps.install(logger, bash_ls_dir) - logger.log("Bash language server dependencies installed successfully", logging.INFO) + log.info(f"Bash Language Server executable not found at {bash_executable_path}. Installing...") + deps.install(bash_ls_dir) + log.info("Bash language server dependencies installed successfully") if not os.path.exists(bash_executable_path): raise FileNotFoundError( @@ -146,11 +142,11 @@ class BashLanguageServer(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") # Check for bash-language-server ready signals message_text = msg.get("message", "") if "Analyzing" in message_text or "analysis complete" in message_text.lower(): - self.logger.log("Bash language server analysis signals detected", logging.INFO) + log.info("Bash language server analysis signals detected") self.server_ready.set() self.completions_available.set() @@ -160,16 +156,13 @@ class BashLanguageServer(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting Bash server process", logging.INFO) + log.info("Starting Bash server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) - self.logger.log(f"Received initialize response from bash server: {init_response}", logging.DEBUG) + log.debug(f"Received initialize response from bash server: {init_response}") # Enhanced capability checks for bash-language-server 5.6.0 assert init_response["capabilities"]["textDocumentSync"] in [1, 2] # Full or Incremental @@ -177,22 +170,22 @@ class BashLanguageServer(SolidLanguageServer): # Verify document symbol support is available if "documentSymbolProvider" in init_response["capabilities"]: - self.logger.log("Bash server supports document symbols", logging.INFO) + log.info("Bash server supports document symbols") else: - self.logger.log("Warning: Bash server does not report document symbol support", logging.WARNING) + log.warning("Warning: Bash server does not report document symbol support") self.server.notify.initialized({}) # Wait for server readiness with timeout - self.logger.log("Waiting for Bash language server to be ready...", logging.INFO) + log.info("Waiting for Bash language server to be ready...") if not self.server_ready.wait(timeout=3.0): # Fallback: assume server is ready after timeout # This is common. bash-language-server doesn't always send explicit ready signals. Log as info - self.logger.log("Timeout waiting for bash server ready signal, proceeding anyway", logging.INFO) + log.info("Timeout waiting for bash server ready signal, proceeding anyway") self.server_ready.set() self.completions_available.set() else: - self.logger.log("Bash server initialization complete", logging.INFO) + log.info("Bash server initialization complete") def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: # Uses the standard LSP documentSymbol request which provides reliable function detection @@ -202,16 +195,13 @@ class BashLanguageServer(SolidLanguageServer): # - Functions with various indentation levels # - Functions with comments before/after/inside - self.logger.log(f"Requesting document symbols via LSP for {relative_file_path}", logging.DEBUG) + log.debug(f"Requesting document symbols via LSP for {relative_file_path}") # Use the standard LSP approach - bash-language-server handles all function syntaxes correctly document_symbols = super().request_document_symbols(relative_file_path, file_buffer=file_buffer) # Log detection results for debugging functions = [s for s in document_symbols.iter_symbols() if s.get("kind") == 12] - self.logger.log( - f"LSP function detection for {relative_file_path}: Found {len(functions)} functions", - logging.INFO, - ) + log.info(f"LSP function detection for {relative_file_path}: Found {len(functions)} functions") return document_symbols diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index d4f67e9b..eccd6017 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -10,13 +10,14 @@ from typing import Any, cast from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + class ClangdLanguageServer(SolidLanguageServer): """ @@ -25,20 +26,13 @@ class ClangdLanguageServer(SolidLanguageServer): Also make sure compile_commands.json is created at root of the source directory. Check clangd test case for example. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a ClangdLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - clangd_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + clangd_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=clangd_executable_path, cwd=repository_root_path), - "cpp", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=clangd_executable_path, cwd=repository_root_path), "cpp", solidlsp_settings ) self.server_ready = threading.Event() self.service_ready_event = threading.Event() @@ -46,9 +40,7 @@ class ClangdLanguageServer(SolidLanguageServer): self.resolve_main_method_available = threading.Event() @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> str: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for ClangdLanguageServer and return the command to start the server. """ @@ -110,16 +102,13 @@ class ClangdLanguageServer(SolidLanguageServer): + " Arch Linux: sudo pacman -S clang\n" + "See https://clangd.llvm.org/installation for more details." ) - logger.log(f"Using system-installed clangd at {clangd_executable_path}", logging.INFO) + log.info(f"Using system-installed clangd at {clangd_executable_path}") else: # Standard download and install for platforms with prebuilt binaries clangd_executable_path = deps.binary_path(clangd_ls_dir) if not os.path.exists(clangd_executable_path): - logger.log( - f"Clangd executable not found at {clangd_executable_path}. Downloading from {dep.url}", - logging.INFO, - ) - _ = deps.install(logger, clangd_ls_dir) + log.info(f"Clangd executable not found at {clangd_executable_path}. Downloading from {dep.url}") + _ = deps.install(clangd_ls_dir) if not os.path.exists(clangd_executable_path): raise FileNotFoundError( f"Clangd executable not found at {clangd_executable_path}.\n" @@ -197,7 +186,7 @@ class ClangdLanguageServer(SolidLanguageServer): self.server_ready.set() def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) @@ -208,14 +197,11 @@ class ClangdLanguageServer(SolidLanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - self.logger.log("Starting Clangd server process", logging.INFO) + log.info("Starting Clangd server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" in init_response["capabilities"] diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index 241e57a0..f32a4d5a 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -12,13 +12,14 @@ from typing import cast from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + def run_command(cmd: list, capture_output: bool = True) -> subprocess.CompletedProcess: return subprocess.run( @@ -86,16 +87,13 @@ class ClojureLSP(SolidLanguageServer): ] ) - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a ClojureLSP instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - clojure_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + clojure_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=clojure_lsp_executable_path, cwd=repository_root_path), "clojure", @@ -107,9 +105,7 @@ class ClojureLSP(SolidLanguageServer): self.service_ready_event = threading.Event() @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> str: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """Setup runtime dependencies for clojure-lsp and return the command to start the server.""" verify_clojure_cli() deps = ClojureLSP.runtime_dependencies @@ -118,11 +114,10 @@ class ClojureLSP(SolidLanguageServer): clojurelsp_ls_dir = cls.ls_resources_dir(solidlsp_settings) clojurelsp_executable_path = deps.binary_path(clojurelsp_ls_dir) if not os.path.exists(clojurelsp_executable_path): - logger.log( + log.info( f"Downloading and extracting clojure-lsp from {dependency.url} to {clojurelsp_ls_dir}", - logging.INFO, ) - deps.install(logger, clojurelsp_ls_dir) + deps.install(clojurelsp_ls_dir) if not os.path.exists(clojurelsp_executable_path): raise FileNotFoundError(f"Download failed? Could not find clojure-lsp executable at {clojurelsp_executable_path}") os.chmod(clojurelsp_executable_path, 0o755) @@ -189,7 +184,7 @@ class ClojureLSP(SolidLanguageServer): self.server_ready.set() def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) @@ -200,15 +195,12 @@ class ClojureLSP(SolidLanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - self.logger.log("Starting clojure-lsp server process", logging.INFO) + log.info("Starting clojure-lsp server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" in init_response["capabilities"] diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index c1cea673..504b0637 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -8,7 +8,6 @@ from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace from typing import Any, cast -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.util.subprocess_util import subprocess_kwargs @@ -79,7 +78,7 @@ class RuntimeDependencyCollection: return target_dir return os.path.join(target_dir, dep.binary_name) - def install(self, logger: LanguageServerLogger, target_dir: str) -> dict[str, str]: + def install(self, target_dir: str) -> dict[str, str]: """Install all dependencies for the current platform into *target_dir*. Returns a mapping from dependency id to the resolved binary path. @@ -88,7 +87,7 @@ class RuntimeDependencyCollection: results: dict[str, str] = {} for dep in self.get_dependencies_for_current_platform(): if dep.url: - self._install_from_url(dep, logger, target_dir) + self._install_from_url(dep, target_dir) if dep.command: self._run_command(dep.command, target_dir) if dep.binary_name: @@ -131,15 +130,15 @@ class RuntimeDependencyCollection: ) @staticmethod - def _install_from_url(dep: RuntimeDependency, logger: LanguageServerLogger, target_dir: str) -> None: + def _install_from_url(dep: RuntimeDependency, target_dir: str) -> None: if not dep.url: raise ValueError(f"Dependency {dep.id} has no URL") if dep.archive_type in ("gz", "binary") and dep.binary_name: dest = os.path.join(target_dir, dep.binary_name) - FileUtils.download_and_extract_archive(logger, dep.url, dest, dep.archive_type) + FileUtils.download_and_extract_archive(dep.url, dest, dep.archive_type) else: - FileUtils.download_and_extract_archive(logger, dep.url, target_dir, dep.archive_type or "zip") + FileUtils.download_and_extract_archive(dep.url, target_dir, dep.archive_type or "zip") def quote_windows_path(path: str) -> str: diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 2382b761..1371e726 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -21,7 +21,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -30,6 +29,8 @@ from solidlsp.util.zip import SafeZipExtractor from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + _RUNTIME_DEPENDENCIES = [ RuntimeDependency( id="CSharpLanguageServer", @@ -198,14 +199,12 @@ class CSharpLanguageServer(SolidLanguageServer): See the `_RUNTIME_DEPENDENCIES` variable above for the available dependency ids and platform_ids. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a CSharpLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - dotnet_path, language_server_path = self._ensure_server_installed(logger, config, solidlsp_settings) + dotnet_path, language_server_path = self._ensure_server_installed(config, solidlsp_settings) # Find solution or project file solution_or_project = find_solution_or_project_file(repository_root_path) @@ -219,20 +218,13 @@ class CSharpLanguageServer(SolidLanguageServer): # The language server will discover the solution/project from the workspace root if solution_or_project: - logger.log(f"Found solution/project file: {solution_or_project}", logging.INFO) + log.info(f"Found solution/project file: {solution_or_project}") else: - logger.log("No .sln or .csproj file found, language server will attempt auto-discovery", logging.WARNING) + log.warning("No .sln or .csproj file found, language server will attempt auto-discovery") - logger.log(f"Language server command: {' '.join(cmd)}", logging.DEBUG) + log.debug(f"Language server command: {' '.join(cmd)}") - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), - "csharp", - solidlsp_settings, - ) + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", solidlsp_settings) self.initialization_complete = threading.Event() @@ -241,9 +233,7 @@ class CSharpLanguageServer(SolidLanguageServer): return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj", "packages", ".vs"] @classmethod - def _ensure_server_installed( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> tuple[str, str]: + def _ensure_server_installed(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[str, str]: """ Ensure .NET runtime and Microsoft.CodeAnalysis.LanguageServer are available. Returns a tuple of (dotnet_path, language_server_dll_path). @@ -251,30 +241,27 @@ class CSharpLanguageServer(SolidLanguageServer): language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance()) runtime_dependency_overrides = cast(list[dict[str, Any]], language_specific_config.get("runtime_dependencies", [])) - logger.log("Resolving runtime dependencies", logging.DEBUG) + log.debug("Resolving runtime dependencies") runtime_dependencies = RuntimeDependencyCollection( _RUNTIME_DEPENDENCIES, overrides=runtime_dependency_overrides, ) - logger.log( + log.debug( f"Available runtime dependencies: {runtime_dependencies.get_dependencies_for_current_platform}", - logging.DEBUG, ) # Find the dependencies for our platform lang_server_dep = runtime_dependencies.get_single_dep_for_current_platform("CSharpLanguageServer") dotnet_runtime_dep = runtime_dependencies.get_single_dep_for_current_platform("DotNetRuntime") - dotnet_path = CSharpLanguageServer._ensure_dotnet_runtime(logger, dotnet_runtime_dep, solidlsp_settings) - server_dll_path = CSharpLanguageServer._ensure_language_server(logger, lang_server_dep, solidlsp_settings) + dotnet_path = CSharpLanguageServer._ensure_dotnet_runtime(dotnet_runtime_dep, solidlsp_settings) + server_dll_path = CSharpLanguageServer._ensure_language_server(lang_server_dep, solidlsp_settings) return dotnet_path, server_dll_path @classmethod - def _ensure_dotnet_runtime( - cls, logger: LanguageServerLogger, dotnet_runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings - ) -> str: + def _ensure_dotnet_runtime(cls, dotnet_runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str: """Ensure .NET runtime is available and return the dotnet executable path.""" # TODO: use RuntimeDependency util methods instead of custom validation/download logic @@ -285,18 +272,16 @@ class CSharpLanguageServer(SolidLanguageServer): try: result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) if "Microsoft.NETCore.App 9." in result.stdout: - logger.log("Found system .NET 9 runtime", logging.INFO) + log.info("Found system .NET 9 runtime") return system_dotnet except subprocess.CalledProcessError: pass # Download .NET 9 runtime using config - return cls._ensure_dotnet_runtime_from_config(logger, dotnet_runtime_dep, solidlsp_settings) + return cls._ensure_dotnet_runtime_from_config(dotnet_runtime_dep, solidlsp_settings) @classmethod - def _ensure_language_server( - cls, logger: LanguageServerLogger, lang_server_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings - ) -> str: + def _ensure_language_server(cls, lang_server_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str: """Ensure language server is available and return the DLL path.""" package_name = lang_server_dep.package_name package_version = lang_server_dep.package_version @@ -306,14 +291,14 @@ class CSharpLanguageServer(SolidLanguageServer): server_dll = server_dir / lang_server_dep.binary_name if server_dll.exists(): - logger.log(f"Using cached Microsoft.CodeAnalysis.LanguageServer from {server_dll}", logging.INFO) + log.info(f"Using cached Microsoft.CodeAnalysis.LanguageServer from {server_dll}") return str(server_dll) # Download and install the language server - logger.log(f"Downloading {package_name} version {package_version}...", logging.INFO) + log.info(f"Downloading {package_name} version {package_version}...") assert package_version is not None assert package_name is not None - package_path = cls._download_nuget_package_direct(logger, package_name, package_version, solidlsp_settings) + package_path = cls._download_nuget_package_direct(package_name, package_version, solidlsp_settings) # Extract and install cls._extract_language_server(lang_server_dep, package_path, server_dir) @@ -325,7 +310,7 @@ class CSharpLanguageServer(SolidLanguageServer): if platform.system().lower() != "windows": server_dll.chmod(0o755) - logger.log(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}", logging.INFO) + log.info(f"Successfully installed Microsoft.CodeAnalysis.LanguageServer to {server_dll}") return str(server_dll) @staticmethod @@ -352,9 +337,7 @@ class CSharpLanguageServer(SolidLanguageServer): shutil.copytree(source_dir, server_dir, dirs_exist_ok=True) @classmethod - def _download_nuget_package_direct( - cls, logger: LanguageServerLogger, package_name: str, package_version: str, solidlsp_settings: SolidLSPSettings - ) -> Path: + def _download_nuget_package_direct(cls, package_name: str, package_version: str, solidlsp_settings: SolidLSPSettings) -> Path: """ Download a NuGet package directly from the Azure NuGet feed. Returns the path to the extracted package directory. @@ -367,7 +350,7 @@ class CSharpLanguageServer(SolidLanguageServer): try: # First, get the service index from the Azure feed - logger.log("Fetching NuGet service index from Azure feed...", logging.DEBUG) + log.debug("Fetching NuGet service index from Azure feed...") with urllib.request.urlopen(azure_feed_url) as response: service_index = json.loads(response.read().decode()) @@ -386,7 +369,7 @@ class CSharpLanguageServer(SolidLanguageServer): package_version_lower = package_version.lower() package_url = f"{package_base_address.rstrip('/')}/{package_id_lower}/{package_version_lower}/{package_id_lower}.{package_version_lower}.nupkg" - logger.log(f"Downloading package from: {package_url}", logging.DEBUG) + log.debug(f"Downloading package from: {package_url}") # Download the .nupkg file nupkg_file = temp_dir / f"{package_name}.{package_version}.nupkg" @@ -403,7 +386,7 @@ class CSharpLanguageServer(SolidLanguageServer): # Clean up the nupkg file nupkg_file.unlink() - logger.log(f"Successfully downloaded and extracted {package_name} version {package_version}", logging.INFO) + log.info(f"Successfully downloaded and extracted {package_name} version {package_version}") return package_extract_dir except Exception as e: @@ -412,9 +395,7 @@ class CSharpLanguageServer(SolidLanguageServer): ) from e @classmethod - def _ensure_dotnet_runtime_from_config( - cls, logger: LanguageServerLogger, dotnet_runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings - ) -> str: + def _ensure_dotnet_runtime_from_config(cls, dotnet_runtime_dep: RuntimeDependency, solidlsp_settings: SolidLSPSettings) -> str: """ Ensure .NET 9 runtime is available using runtime dependency configuration. Returns the path to the dotnet executable. @@ -428,7 +409,7 @@ class CSharpLanguageServer(SolidLanguageServer): try: result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) if "Microsoft.NETCore.App 9." in result.stdout: - logger.log("Found system .NET 9 runtime", logging.INFO) + log.info("Found system .NET 9 runtime") return system_dotnet except subprocess.CalledProcessError: pass @@ -439,17 +420,17 @@ class CSharpLanguageServer(SolidLanguageServer): dotnet_exe = dotnet_dir / dotnet_runtime_dep.binary_name if dotnet_exe.exists(): - logger.log(f"Using cached .NET runtime from {dotnet_exe}", logging.INFO) + log.info(f"Using cached .NET runtime from {dotnet_exe}") return str(dotnet_exe) # Download .NET runtime - logger.log("Downloading .NET 9 runtime...", logging.INFO) + log.info("Downloading .NET 9 runtime...") dotnet_dir.mkdir(parents=True, exist_ok=True) custom_settings = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance()) custom_dotnet_runtime_url = custom_settings.get("dotnet_runtime_url") if custom_dotnet_runtime_url is not None: - logger.log(f"Using custom .NET runtime url: {custom_dotnet_runtime_url}", logging.INFO) + log.info(f"Using custom .NET runtime url: {custom_dotnet_runtime_url}") url = custom_dotnet_runtime_url else: url = dotnet_runtime_dep.url @@ -459,7 +440,7 @@ class CSharpLanguageServer(SolidLanguageServer): # Download the runtime download_path = dotnet_dir / f"dotnet-runtime.{archive_type}" try: - logger.log(f"Downloading from {url}", logging.DEBUG) + log.debug(f"Downloading from {url}") urllib.request.urlretrieve(url, download_path) # Extract the archive @@ -478,7 +459,7 @@ class CSharpLanguageServer(SolidLanguageServer): if platform.system().lower() != "windows": dotnet_exe.chmod(0o755) - logger.log(f"Successfully installed .NET 9 runtime to {dotnet_exe}", logging.INFO) + log.info(f"Successfully installed .NET 9 runtime to {dotnet_exe}") return str(dotnet_exe) except Exception as e: @@ -551,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 - self.logger.log(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.""" @@ -559,7 +540,7 @@ class CSharpLanguageServer(SolidLanguageServer): value = params.get("value", {}) # Log raw progress for debugging - self.logger.log(f"Progress notification received: {params}", logging.DEBUG) + log.debug(f"Progress notification received: {params}") # Handle different progress notification types kind = value.get("kind") @@ -570,22 +551,22 @@ class CSharpLanguageServer(SolidLanguageServer): percentage = value.get("percentage") if percentage is not None: - self.logger.log(f"Progress [{token}]: {title} - {message} ({percentage}%)", logging.INFO) + log.debug(f"Progress [{token}]: {title} - {message} ({percentage}%)") else: - self.logger.log(f"Progress [{token}]: {title} - {message}", logging.INFO) + log.info(f"Progress [{token}]: {title} - {message}") elif kind == "report": message = value.get("message", "") percentage = value.get("percentage") if percentage is not None: - self.logger.log(f"Progress [{token}]: {message} ({percentage}%)", logging.INFO) + log.info(f"Progress [{token}]: {message} ({percentage}%)") elif message: - self.logger.log(f"Progress [{token}]: {message}", logging.INFO) + log.info(f"Progress [{token}]: {message}") elif kind == "end": message = value.get("message", "Operation completed") - self.logger.log(f"Progress [{token}]: {message}", logging.INFO) + log.info(f"Progress [{token}]: {message}") def handle_workspace_configuration(params: dict) -> list: """Handle workspace/configuration requests from the server.""" @@ -646,30 +627,34 @@ 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) self.server.on_request("workspace/_roslyn_projectNeedsRestore", handle_project_needs_restore) - self.logger.log("Starting Microsoft.CodeAnalysis.LanguageServer process", logging.INFO) + log.info("Starting Microsoft.CodeAnalysis.LanguageServer process") try: self.server.start() except Exception as e: - self.logger.log(f"Failed to start language server process: {e}", logging.ERROR) + log.info(f"Failed to start language server process: {e}", logging.ERROR) raise SolidLSPException(f"Failed to start C# language server: {e}") # Send initialization initialize_params = self._get_initialize_params() - self.logger.log("Sending initialize request to language server", logging.INFO) + log.info("Sending initialize request to language server") try: init_response = self.server.send.initialize(initialize_params) - self.logger.log(f"Received initialize response: {init_response}", logging.DEBUG) + log.info(f"Received initialize response: {init_response}") except Exception as e: raise SolidLSPException(f"Failed to initialize C# language server for {self.repository_root_path}: {e}") from e @@ -698,15 +683,19 @@ class CSharpLanguageServer(SolidLanguageServer): self._open_solution_and_projects() self.initialization_complete.set() - self.completions_available.set() - self.logger.log( + log.info( "Microsoft.CodeAnalysis.LanguageServer initialized and ready\n" "Waiting for language server to index project files...\n" - "This may take a while for large projects", - logging.INFO, + "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. @@ -724,7 +713,7 @@ class CSharpLanguageServer(SolidLanguageServer): "workspaceDiagnostics": True, } ) - self.logger.log("Applied diagnostic capabilities hack for better C# diagnostics", logging.DEBUG) + log.debug("Applied diagnostic capabilities hack for better C# diagnostics") def _open_solution_and_projects(self) -> None: """ @@ -741,7 +730,7 @@ class CSharpLanguageServer(SolidLanguageServer): if solution_file: solution_uri = PathUtils.path_to_uri(solution_file) self.server.notify.send_notification("solution/open", {"solution": solution_uri}) - self.logger.log(f"Opened solution file: {solution_file}", logging.INFO) + log.debug(f"Opened solution file: {solution_file}") # Find and open project files project_files = [] @@ -753,7 +742,7 @@ class CSharpLanguageServer(SolidLanguageServer): if project_files: project_uris = [PathUtils.path_to_uri(project_file) for project_file in project_files] self.server.notify.send_notification("project/open", {"projects": project_uris}) - self.logger.log(f"Opened project files: {project_files}", logging.DEBUG) + log.debug(f"Opened project files: {project_files}") @override def _get_wait_time_for_cross_file_referencing(self) -> float: diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index 0135ae7a..12dc3143 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -4,7 +4,6 @@ import pathlib from typing import cast from solidlsp.ls import SolidLanguageServer -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -12,30 +11,25 @@ from ..ls_config import LanguageServerConfig from ..lsp_protocol_handler.lsp_types import InitializeParams from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + class DartLanguageServer(SolidLanguageServer): """ Provides Dart specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Dart. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ) -> None: + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings) -> None: """ Creates a DartServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - executable_path = self._setup_runtime_dependencies(logger, solidlsp_settings) + executable_path = self._setup_runtime_dependencies(solidlsp_settings) super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), - "dart", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=executable_path, cwd=repository_root_path), "dart", solidlsp_settings ) @classmethod - def _setup_runtime_dependencies(cls, logger: "LanguageServerLogger", solidlsp_settings: SolidLSPSettings) -> str: + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: deps = RuntimeDependencyCollection( [ RuntimeDependency( @@ -85,7 +79,7 @@ class DartLanguageServer(SolidLanguageServer): dart_executable_path = deps.binary_path(dart_ls_dir) if not os.path.exists(dart_executable_path): - deps.install(logger, dart_ls_dir) + deps.install(dart_ls_dir) assert os.path.exists(dart_executable_path) os.chmod(dart_executable_path, 0o755) @@ -136,7 +130,7 @@ class DartLanguageServer(SolidLanguageServer): pass def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("language/status", do_nothing) @@ -147,17 +141,11 @@ class DartLanguageServer(SolidLanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - self.logger.log("Starting dart-language-server server process", logging.INFO) + log.info("Starting dart-language-server server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request to dart-language-server", - logging.DEBUG, - ) + log.debug("Sending initialize request to dart-language-server") init_response = self.server.send_request("initialize", initialize_params) # type: ignore - self.logger.log( - f"Received initialize response from dart-language-server: {init_response}", - logging.INFO, - ) + log.info(f"Received initialize response from dart-language-server: {init_response}") self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 7c718c07..d058521e 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -16,13 +16,14 @@ from overrides import override from solidlsp.ls import LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + @dataclasses.dataclass class RuntimeDependencyPaths: @@ -61,14 +62,12 @@ class EclipseJDTLS(SolidLanguageServer): ``` """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a new EclipseJDTLS instance initializing the language server settings appropriately. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - runtime_dependency_paths = self._setupRuntimeDependencies(logger, config, solidlsp_settings) + runtime_dependency_paths = self._setupRuntimeDependencies(config, solidlsp_settings) self.runtime_dependency_paths = runtime_dependency_paths # ws_dir is the workspace directory for the EclipseJDTLS server @@ -150,7 +149,7 @@ class EclipseJDTLS(SolidLanguageServer): self.initialize_searcher_command_available = threading.Event() super().__init__( - config, logger, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java", solidlsp_settings=solidlsp_settings + config, repository_root_path, ProcessLaunchInfo(cmd, proc_env, proc_cwd), "java", solidlsp_settings=solidlsp_settings ) @override @@ -172,9 +171,7 @@ class EclipseJDTLS(SolidLanguageServer): ] @classmethod - def _setupRuntimeDependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> RuntimeDependencyPaths: + def _setupRuntimeDependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> RuntimeDependencyPaths: """ Setup runtime dependencies for EclipseJDTLS and return the paths. """ @@ -266,7 +263,6 @@ class EclipseJDTLS(SolidLanguageServer): if not os.path.exists(gradle_path): FileUtils.download_and_extract_archive( - logger, runtime_dependencies["gradle"]["platform-agnostic"]["url"], str(PurePath(gradle_path).parent), runtime_dependencies["gradle"]["platform-agnostic"]["archiveType"], @@ -292,7 +288,7 @@ class EclipseJDTLS(SolidLanguageServer): os.path.exists(jdtls_readonly_config_path), ] ): - FileUtils.download_and_extract_archive(logger, dependency["url"], vscode_java_path, dependency["archiveType"]) + FileUtils.download_and_extract_archive(dependency["url"], vscode_java_path, dependency["archiveType"]) os.chmod(jre_path, 0o755) @@ -315,7 +311,7 @@ class EclipseJDTLS(SolidLanguageServer): os.path.exists(intellisense_members_path), ] ): - FileUtils.download_and_extract_archive(logger, dependency["url"], intellicode_directory_path, dependency["archiveType"]) + FileUtils.download_and_extract_archive(dependency["url"], intellicode_directory_path, dependency["archiveType"]) assert os.path.exists(intellicode_directory_path) assert os.path.exists(intellicode_jar_path) @@ -355,18 +351,16 @@ class EclipseJDTLS(SolidLanguageServer): f"Fix: create the file, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> maven_user_settings), " f"or remove the setting to use default ({default_maven_settings_path})" ) - self.logger.log(error_msg, logging.ERROR) + log.error(error_msg) raise FileNotFoundError(error_msg) maven_settings_path = custom_maven_settings_path - self.logger.log(f"Using Maven settings from custom location: {maven_settings_path}", logging.INFO) + log.info(f"Using Maven settings from custom location: {maven_settings_path}") elif os.path.exists(default_maven_settings_path): maven_settings_path = default_maven_settings_path - self.logger.log(f"Using Maven settings from default location: {maven_settings_path}", logging.INFO) + log.info(f"Using Maven settings from default location: {maven_settings_path}") else: maven_settings_path = None - self.logger.log( - f"Maven settings not found at default location ({default_maven_settings_path}), will use JDTLS defaults", logging.INFO - ) + log.info(f"Maven settings not found at default location ({default_maven_settings_path}), will use JDTLS defaults") # Gradle user home: default to ~/.gradle default_gradle_home = os.path.join(os.path.expanduser("~"), ".gradle") @@ -379,18 +373,16 @@ class EclipseJDTLS(SolidLanguageServer): f"Fix: create the directory, update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_user_home), " f"or remove the setting to use default (~/.gradle)" ) - self.logger.log(error_msg, logging.ERROR) + log.error(error_msg) raise FileNotFoundError(error_msg) gradle_user_home = custom_gradle_home - self.logger.log(f"Using Gradle user home from custom location: {gradle_user_home}", logging.INFO) + log.info(f"Using Gradle user home from custom location: {gradle_user_home}") elif os.path.exists(default_gradle_home): gradle_user_home = default_gradle_home - self.logger.log(f"Using Gradle user home from default location: {gradle_user_home}", logging.INFO) + log.info(f"Using Gradle user home from default location: {gradle_user_home}") else: gradle_user_home = None - self.logger.log( - f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults", logging.INFO - ) + log.info(f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults") initialize_params = { "locale": "en", @@ -745,7 +737,7 @@ class EclipseJDTLS(SolidLanguageServer): return [] def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return @@ -758,14 +750,11 @@ class EclipseJDTLS(SolidLanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) - self.logger.log("Starting EclipseJDTLS server process", logging.INFO) + log.info("Starting EclipseJDTLS server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" not in init_response["capabilities"] diff --git a/src/solidlsp/language_servers/elixir_tools/README.md b/src/solidlsp/language_servers/elixir_tools/README.md index b3c56d17..56f2e03c 100644 --- a/src/solidlsp/language_servers/elixir_tools/README.md +++ b/src/solidlsp/language_servers/elixir_tools/README.md @@ -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. \ No newline at end of file +The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior. diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index fa14d3e0..de13725e 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -10,24 +10,24 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger 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 from ..common import RuntimeDependency +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: @@ -37,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: @@ -48,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): - self.logger.log(f"Filtering out Next LS internal file: {abs_path}", logging.DEBUG) - 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.""" @@ -96,12 +61,10 @@ class ElixirTools(SolidLanguageServer): return None @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> str: + 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() @@ -110,118 +73,139 @@ class ElixirTools(SolidLanguageServer): "Elixir is not installed. Please install Elixir from https://elixir-lang.org/install.html and make sure it is added to your PATH." ) - logger.log(f"Found Elixir: {elixir_version}", logging.INFO) + log.info(f"Found Elixir: {elixir_version}") + + # First, check if expert is already in PATH (user may have installed it manually) + import shutil + + 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() - # 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." - ) - 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): - logger.log(f"Downloading Next LS binary from {dependency.url}", logging.INFO) + log.info(f"Downloading Expert binary from {dependency.url}") assert dependency.url is not None - FileUtils.download_file(logger, dependency.url, binary_path) + 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}" - logger.log(f"Next LS binary ready at: {executable_path}", logging.INFO) + log.info(f"Expert binary ready at: {executable_path}") return executable_path - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): - nextls_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + expert_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, - logger, 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", @@ -279,92 +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", "") - self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) - # 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: - self.logger.log("Next LS runtime is ready based on official log message", logging.INFO) - 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: - self.logger.log("Next LS initialization progress completed", logging.INFO) - # 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": - self.logger.log("Next LS work done progress completed", logging.INFO) - # 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) - self.logger.log("Starting Next LS server process", logging.INFO) + log.debug("Starting Expert server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.debug("Sending initialize request to Expert") init_response = self.server.send.initialize(initialize_params) - # Verify server capabilities - be more lenient with Next LS - self.logger.log(f"Next LS capabilities: {list(init_response['capabilities'].keys())}", logging.INFO) - - # 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"]: - self.logger.log("completionProvider not available in initial capabilities", logging.INFO) - - if "definitionProvider" not in init_response["capabilities"]: - self.logger.log("definitionProvider not available in initial capabilities", logging.INFO) - 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 - self.logger.log(f"Waiting up to {ready_timeout} seconds for Next LS runtime readiness...", logging.INFO) - + # 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): - self.logger.log("Next LS is ready and available for requests", logging.INFO) - + 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." - self.logger.log(error_msg, logging.ERROR) - 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 diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index f75af40c..0a4c25bd 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -13,33 +13,27 @@ from sensai.util.logging import LogTime from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + class ElmLanguageServer(SolidLanguageServer): """ Provides Elm specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Elm. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates an ElmLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - elm_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + elm_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=elm_lsp_executable_path, cwd=repository_root_path), - "elm", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=elm_lsp_executable_path, cwd=repository_root_path), "elm", solidlsp_settings ) self.server_ready = threading.Event() @@ -53,16 +47,14 @@ class ElmLanguageServer(SolidLanguageServer): ] @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> list[str]: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: """ Setup runtime dependencies for Elm Language Server and return the command to start the server. """ # Check if elm-language-server is already installed globally system_elm_ls = shutil.which("elm-language-server") if system_elm_ls: - logger.log(f"Found system-installed elm-language-server at {system_elm_ls}", logging.INFO) + log.info(f"Found system-installed elm-language-server at {system_elm_ls}") return [system_elm_ls, "--stdio"] # Verify node and npm are installed @@ -86,9 +78,9 @@ class ElmLanguageServer(SolidLanguageServer): elm_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "elm-lsp") elm_ls_executable_path = os.path.join(elm_ls_dir, "node_modules", ".bin", "elm-language-server") if not os.path.exists(elm_ls_executable_path): - logger.log(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...", logging.INFO) - with LogTime("Installation of Elm language server dependencies", logger=logger.logger): - deps.install(logger, elm_ls_dir) + log.info(f"Elm Language Server executable not found at {elm_ls_executable_path}. Installing...") + with LogTime("Installation of Elm language server dependencies", logger=log): + deps.install(elm_ls_dir) if not os.path.exists(elm_ls_executable_path): raise FileNotFoundError( @@ -154,20 +146,17 @@ class ElmLanguageServer(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting Elm server process", logging.INFO) + log.info("Starting Elm server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Elm-specific capability checks @@ -178,11 +167,11 @@ class ElmLanguageServer(SolidLanguageServer): assert "documentSymbolProvider" in init_response["capabilities"] self.server.notify.initialized({}) - self.logger.log("Elm server initialized successfully, waiting for workspace scan...", logging.INFO) + log.info("Elm server initialized successfully, waiting for workspace scan...") self.server_ready.set() self.completions_available.set() - self.logger.log("Elm server ready", logging.INFO) + log.info("Elm server ready") @override def _get_wait_time_for_cross_file_referencing(self) -> float: diff --git a/src/solidlsp/language_servers/erlang_language_server.py b/src/solidlsp/language_servers/erlang_language_server.py index 36651688..065f96bf 100644 --- a/src/solidlsp/language_servers/erlang_language_server.py +++ b/src/solidlsp/language_servers/erlang_language_server.py @@ -11,21 +11,16 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class ErlangLanguageServer(SolidLanguageServer): """Language server for Erlang using Erlang LS.""" - def __init__( - self, - config: LanguageServerConfig, - logger: LanguageServerLogger, - repository_root_path: str, - solidlsp_settings: SolidLSPSettings, - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates an ErlangLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. @@ -39,7 +34,6 @@ class ErlangLanguageServer(SolidLanguageServer): super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=[self.erlang_ls_path, "--transport", "stdio"], cwd=repository_root_path), "erlang", @@ -89,7 +83,7 @@ class ErlangLanguageServer(SolidLanguageServer): def window_log_message(msg: dict) -> None: """Handle window/logMessage notifications from Erlang LS""" message_text = msg.get("message", "") - self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) + log.info(f"LSP: window/logMessage: {message_text}") # Look for Erlang LS readiness signals # Common patterns: "Started Erlang LS", "initialized", "ready" @@ -105,7 +99,7 @@ class ErlangLanguageServer(SolidLanguageServer): message_lower = message_text.lower() for signal in readiness_signals: if signal.lower() in message_lower: - self.logger.log(f"Erlang LS readiness signal detected: {message_text}", logging.INFO) + log.info(f"Erlang LS readiness signal detected: {message_text}") self.server_ready.set() break @@ -120,7 +114,7 @@ class ErlangLanguageServer(SolidLanguageServer): if value.get("kind") == "end": message = value.get("message", "") if any(word in message.lower() for word in ["initialized", "ready", "complete"]): - self.logger.log("Erlang LS initialization progress completed", logging.INFO) + log.info("Erlang LS initialization progress completed") # Set as fallback if no window/logMessage was received if not self.server_ready.is_set(): self.server_ready.set() @@ -133,7 +127,7 @@ class ErlangLanguageServer(SolidLanguageServer): self.server.on_notification("$/workDoneProgress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting Erlang LS server process", logging.INFO) + log.info("Starting Erlang LS server process") self.server.start() # Send initialize request @@ -153,12 +147,12 @@ class ErlangLanguageServer(SolidLanguageServer): }, } - self.logger.log("Sending initialize request to Erlang LS", logging.INFO) + log.info("Sending initialize request to Erlang LS") init_response = self.server.send.initialize(initialize_params) # type: ignore[arg-type] # Verify server capabilities if "capabilities" in init_response: - self.logger.log(f"Erlang LS capabilities: {list(init_response['capabilities'].keys())}", logging.INFO) + log.info(f"Erlang LS capabilities: {list(init_response['capabilities'].keys())}") self.server.notify.initialized({}) self.completions_available.set() @@ -178,28 +172,26 @@ class ErlangLanguageServer(SolidLanguageServer): ready_timeout = 60.0 # 1 minute for local env_desc = "local" - self.logger.log(f"Waiting up to {ready_timeout} seconds for Erlang LS readiness ({env_desc} environment)...", logging.INFO) + log.info(f"Waiting up to {ready_timeout} seconds for Erlang LS readiness ({env_desc} environment)...") if self.server_ready.wait(timeout=ready_timeout): - self.logger.log("Erlang LS is ready and available for requests", logging.INFO) + log.info("Erlang LS is ready and available for requests") # Add settling period for indexing - adjust based on environment settling_time = 15.0 if is_ci else 5.0 - self.logger.log(f"Allowing {settling_time} seconds for Erlang LS indexing to complete...", logging.INFO) + log.info(f"Allowing {settling_time} seconds for Erlang LS indexing to complete...") time.sleep(settling_time) - self.logger.log("Erlang LS settling period complete", logging.INFO) + log.info("Erlang LS settling period complete") else: # Set ready anyway and continue - Erlang LS might not send explicit ready messages - self.logger.log( - f"Erlang LS readiness timeout reached after {ready_timeout}s, proceeding anyway (common in CI)", logging.WARNING - ) + log.warning(f"Erlang LS readiness timeout reached after {ready_timeout}s, proceeding anyway (common in CI)") self.server_ready.set() # Still give some time for basic initialization even without explicit readiness signal basic_settling_time = 20.0 if is_ci else 10.0 - self.logger.log(f"Allowing {basic_settling_time} seconds for basic Erlang LS initialization...", logging.INFO) + log.info(f"Allowing {basic_settling_time} seconds for basic Erlang LS initialization...") time.sleep(basic_settling_time) - self.logger.log("Basic Erlang LS initialization period complete", logging.INFO) + log.info("Basic Erlang LS initialization period complete") @override def is_ignored_dirname(self, dirname: str) -> bool: diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 993c21fa..406ab182 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -14,11 +14,12 @@ from overrides import override from solidlsp import ls_types from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class FortranLanguageServer(SolidLanguageServer): """Fortran Language Server implementation using fortls.""" @@ -127,10 +128,7 @@ class FortranLanguageServer(SolidLanguageServer): corrected_symbol = symbol.copy() corrected_symbol["selectionRange"] = new_sel_range # type: ignore[typeddict-item] - self.logger.log( - f"Fixed fortls selectionRange for {identifier_name}: char {start_char} -> {identifier_start}", - logging.DEBUG, - ) + log.debug(f"Fixed fortls selectionRange for {identifier_name}: char {start_char} -> {identifier_start}") return corrected_symbol @@ -181,13 +179,7 @@ class FortranLanguageServer(SolidLanguageServer): raise RuntimeError("fortls is not installed or not in PATH.\nInstall it with: pip install fortls") return fortls_path - def __init__( - self, - config: LanguageServerConfig, - logger: LanguageServerLogger, - repository_root_path: str, - solidlsp_settings: SolidLSPSettings, - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Check fortls installation fortls_path = self._check_fortls_installation() @@ -196,12 +188,7 @@ class FortranLanguageServer(SolidLanguageServer): fortls_cmd = f"{fortls_path}" super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=fortls_cmd, cwd=repository_root_path), - "fortran", # Language ID for LSP - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=fortls_cmd, cwd=repository_root_path), "fortran", solidlsp_settings ) self.server_ready = threading.Event() @@ -261,7 +248,7 @@ class FortranLanguageServer(SolidLanguageServer): """Start Fortran Language Server process.""" def window_log_message(msg: dict) -> None: - self.logger.log(f"Fortran LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"Fortran LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return @@ -275,14 +262,11 @@ class FortranLanguageServer(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting Fortran Language Server (fortls) process", logging.INFO) + log.info("Starting Fortran Language Server (fortls) process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request to Fortran Language Server", - logging.INFO, - ) + log.info("Sending initialize request to Fortran Language Server") init_response = self.server.send.initialize(initialize_params) @@ -290,17 +274,17 @@ class FortranLanguageServer(SolidLanguageServer): capabilities = init_response.get("capabilities", {}) assert "textDocumentSync" in capabilities if "completionProvider" in capabilities: - self.logger.log("Fortran LSP completion provider available", logging.INFO) + log.info("Fortran LSP completion provider available") if "definitionProvider" in capabilities: - self.logger.log("Fortran LSP definition provider available", logging.INFO) + log.info("Fortran LSP definition provider available") if "referencesProvider" in capabilities: - self.logger.log("Fortran LSP references provider available", logging.INFO) + log.info("Fortran LSP references provider available") if "documentSymbolProvider" in capabilities: - self.logger.log("Fortran LSP document symbol provider available", logging.INFO) + log.info("Fortran LSP document symbol provider available") self.server.notify.initialized({}) self.completions_available.set() # Fortran Language Server is ready after initialization self.server_ready.set() - self.logger.log("Fortran Language Server initialization complete", logging.INFO) + log.info("Fortran Language Server initialization complete") diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py new file mode 100644 index 00000000..e4ee3e2c --- /dev/null +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -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 diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 9f9aec7a..afb628b9 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -9,11 +9,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class Gopls(SolidLanguageServer): """ @@ -90,19 +91,10 @@ class Gopls(SolidLanguageServer): return True - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): self._setup_runtime_dependency() - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), - "go", - solidlsp_settings, - ) + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", solidlsp_settings) self.server_ready = threading.Event() self.request_id = 0 @@ -145,7 +137,7 @@ class Gopls(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return @@ -155,14 +147,11 @@ class Gopls(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting gopls server process", logging.INFO) + log.info("Starting gopls server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py new file mode 100644 index 00000000..1c302de7 --- /dev/null +++ b/src/solidlsp/language_servers/groovy_language_server.py @@ -0,0 +1,274 @@ +""" +Provides Groovy specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Groovy. +""" + +import dataclasses +import logging +import os +import pathlib +import shlex + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import FileUtils, PlatformUtils +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__) + + +@dataclasses.dataclass +class GroovyRuntimeDependencyPaths: + """ + Stores the paths to the runtime dependencies of Groovy Language Server + """ + + java_path: str + java_home_path: str + ls_jar_path: str + groovy_home_path: str | None = None + + +class GroovyLanguageServer(SolidLanguageServer): + """ + Provides Groovy specific instantiation of the LanguageServer class. + Contains various configurations and settings specific to Groovy. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a Groovy Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + runtime_dependency_paths = self._setup_runtime_dependencies(solidlsp_settings) + self.runtime_dependency_paths = runtime_dependency_paths + + # Get jar options from configuration + ls_jar_options = [] + + if solidlsp_settings.ls_specific_settings: + groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) + jar_options_str = groovy_settings.get("ls_jar_options", "") + if jar_options_str: + ls_jar_options = shlex.split(jar_options_str) + log.info(f"Using Groovy LS JAR options from configuration: {jar_options_str}") + + # Create command to execute the Groovy Language Server + cmd = [self.runtime_dependency_paths.java_path, "-jar", self.runtime_dependency_paths.ls_jar_path] + cmd.extend(ls_jar_options) + + # Set environment variables including JAVA_HOME + proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} + + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), + "groovy", + solidlsp_settings, + ) + + log.info(f"Starting Groovy Language Server with jar options: {ls_jar_options}") + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> GroovyRuntimeDependencyPaths: + """ + Setup runtime dependencies for Groovy Language Server and return paths. + """ + platform_id = PlatformUtils.get_platform_id() + + # Verify platform support + assert ( + platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-") + ), "Only Windows, Linux and macOS platforms are supported for Groovy in multilspy at the moment" + + # Check if user specified custom Java home path + java_home_path = None + java_path = None + + if solidlsp_settings and solidlsp_settings.ls_specific_settings: + groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) + custom_java_home = groovy_settings.get("ls_java_home_path") + if custom_java_home: + log.info(f"Using custom Java home path from configuration: {custom_java_home}") + java_home_path = custom_java_home + + # Determine java executable path based on platform + if platform_id.value.startswith("win-"): + java_path = os.path.join(java_home_path, "bin", "java.exe") + else: + java_path = os.path.join(java_home_path, "bin", "java") + + # If no custom Java home path, download and use bundled Java + if java_home_path is None: + # Runtime dependency information + runtime_dependencies = { + "java": { + "win-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-win32-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-win32-x86_64", + "java_path": "extension/jre/21.0.7-win32-x86_64/bin/java.exe", + }, + "linux-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-linux-x86_64", + "java_path": "extension/jre/21.0.7-linux-x86_64/bin/java", + }, + "linux-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-linux-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-linux-aarch64", + "java_path": "extension/jre/21.0.7-linux-aarch64/bin/java", + }, + "osx-x64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-x64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-macosx-x86_64", + "java_path": "extension/jre/21.0.7-macosx-x86_64/bin/java", + }, + "osx-arm64": { + "url": "https://github.com/redhat-developer/vscode-java/releases/download/v1.42.0/java-darwin-arm64-1.42.0-561.vsix", + "archiveType": "zip", + "java_home_path": "extension/jre/21.0.7-macosx-aarch64", + "java_path": "extension/jre/21.0.7-macosx-aarch64/bin/java", + }, + }, + } + + java_dependency = runtime_dependencies["java"][platform_id.value] + + static_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "groovy_language_server") + os.makedirs(static_dir, exist_ok=True) + + java_dir = os.path.join(static_dir, "java") + os.makedirs(java_dir, exist_ok=True) + + java_home_path = os.path.join(java_dir, java_dependency["java_home_path"]) + java_path = os.path.join(java_dir, java_dependency["java_path"]) + + if not os.path.exists(java_path): + log.info(f"Downloading Java for {platform_id.value}...") + FileUtils.download_and_extract_archive(java_dependency["url"], java_dir, java_dependency["archiveType"]) + + if not platform_id.value.startswith("win-"): + os.chmod(java_path, 0o755) + + assert java_path and os.path.exists(java_path), f"Java executable not found at {java_path}" + + ls_jar_path = cls._find_groovy_ls_jar(solidlsp_settings) + + return GroovyRuntimeDependencyPaths(java_path=java_path, java_home_path=java_home_path, ls_jar_path=ls_jar_path) + + @classmethod + def _find_groovy_ls_jar(cls, solidlsp_settings: SolidLSPSettings) -> str: + """ + Find Groovy Language Server JAR file + """ + if solidlsp_settings and solidlsp_settings.ls_specific_settings: + groovy_settings = solidlsp_settings.get_ls_specific_settings(Language.GROOVY) + config_jar_path = groovy_settings.get("ls_jar_path") + if config_jar_path and os.path.exists(config_jar_path): + log.info(f"Using Groovy LS JAR from configuration: {config_jar_path}") + return config_jar_path + + # if JAR not found + raise RuntimeError( + "Groovy Language Server JAR not found. To use Groovy language support:\n" + "Set 'ls_jar_path' in groovy settings in serena_config.yml:\n" + " ls_specific_settings:\n" + " groovy:\n" + " ls_jar_path: '/path/to/groovy-language-server.jar'\n" + " Ensure the JAR file is available at the configured path\n" + ) + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Groovy Language Server. + """ + if not os.path.isabs(repository_absolute_path): + repository_absolute_path = os.path.abspath(repository_absolute_path) + + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "clientInfo": {"name": "Serena Groovy Client", "version": "1.0.0"}, + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"dynamicRegistration": True, "didSave": True}, + "completion": {"dynamicRegistration": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": {"dynamicRegistration": True}, + "workspaceSymbol": {"dynamicRegistration": True}, + "signatureHelp": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + }, + }, + "initializationOptions": { + "settings": { + "groovy": { + "classpath": [], + "diagnostics": {"enabled": True}, + "completion": {"enabled": True}, + } + }, + }, + "processId": os.getpid(), + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """ + Starts the Groovy Language Server + """ + + def execute_client_command_handler(params: dict) -> list: + return [] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", do_nothing) + self.server.on_notification("language/status", do_nothing) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("language/actionableNotification", do_nothing) + + log.info("Starting Groovy 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) + + capabilities = init_response["capabilities"] + assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" + assert "hoverProvider" in capabilities, "Server must support hover" + assert "completionProvider" in capabilities, "Server must support code completion" + assert "signatureHelpProvider" in capabilities, "Server must support signature help" + assert "definitionProvider" in capabilities, "Server must support go to definition" + assert "referencesProvider" in capabilities, "Server must support find references" + assert "documentSymbolProvider" in capabilities, "Server must support document symbols" + assert "workspaceSymbolProvider" in capabilities, "Server must support workspace symbols" + + self.server.notify.initialized({}) + self.completions_available.set() diff --git a/src/solidlsp/language_servers/haskell_language_server.py b/src/solidlsp/language_servers/haskell_language_server.py index f1353a34..e4e13bba 100644 --- a/src/solidlsp/language_servers/haskell_language_server.py +++ b/src/solidlsp/language_servers/haskell_language_server.py @@ -13,11 +13,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class HaskellLanguageServer(SolidLanguageServer): """ @@ -71,14 +72,12 @@ class HaskellLanguageServer(SolidLanguageServer): " - Homebrew (macOS): brew install haskell-language-server" ) - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a HaskellLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ hls_executable_path = self._ensure_hls_installed() - logger.log(f"Using haskell-language-server at: {hls_executable_path}", logging.INFO) + log.info(f"Using haskell-language-server at: {hls_executable_path}") # Check if there's a haskell subdirectory with Stack/Cabal project haskell_subdir = os.path.join(repository_root_path, "haskell") @@ -86,7 +85,7 @@ class HaskellLanguageServer(SolidLanguageServer): os.path.exists(os.path.join(haskell_subdir, "stack.yaml")) or os.path.exists(os.path.join(haskell_subdir, "cabal.project")) ): working_dir = haskell_subdir - logger.log(f"Using Haskell project directory: {working_dir}", logging.INFO) + log.info(f"Using Haskell project directory: {working_dir}") else: working_dir = repository_root_path @@ -98,7 +97,6 @@ class HaskellLanguageServer(SolidLanguageServer): super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=[hls_executable_path, "--lsp", "--cwd", working_dir], cwd=working_dir, env=env), "haskell", @@ -322,19 +320,19 @@ class HaskellLanguageServer(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def register_capability_handler(params: dict) -> None: """Handle dynamic capability registration from HLS""" if "registrations" in params: for registration in params.get("registrations", []): method = registration.get("method", "") - self.logger.log(f"HLS registered capability: {method}", logging.INFO) + log.info(f"HLS registered capability: {method}") return def workspace_configuration_handler(params: dict) -> Any: """Handle workspace/configuration requests from HLS""" - self.logger.log(f"HLS requesting configuration: {params}", logging.INFO) + log.info(f"HLS requesting configuration: {params}") # Configuration matching VS Code settings and initialization options haskell_config = { @@ -352,7 +350,7 @@ class HaskellLanguageServer(SolidLanguageServer): result.append(haskell_config) else: result.append({}) - self.logger.log(f"Returning configuration: {result}", logging.INFO) + log.info(f"Returning configuration: {result}") return result # Fallback: return single config @@ -364,26 +362,23 @@ class HaskellLanguageServer(SolidLanguageServer): self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_request("workspace/configuration", workspace_configuration_handler) - self.logger.log("Starting Haskell Language Server process", logging.INFO) + log.info("Starting Haskell Language Server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Log capabilities returned by HLS capabilities = init_response.get("capabilities", {}) - self.logger.log(f"HLS capabilities: {list(capabilities.keys())}", logging.INFO) + log.info(f"HLS capabilities: {list(capabilities.keys())}") self.server.notify.initialized({}) self.completions_available.set() # Give HLS time to index the project # HLS can be slow to index, especially on first run - self.logger.log("Waiting for HLS to index project...", logging.INFO) + log.info("Waiting for HLS to index project...") time.sleep(5) - self.logger.log("Haskell Language Server initialized successfully", logging.INFO) + log.info("Haskell Language Server initialized successfully") diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index dc62a70e..64f80e03 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -12,7 +12,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -21,6 +20,8 @@ from solidlsp.settings import SolidLSPSettings from ..lsp_protocol_handler import lsp_types from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + class Intelephense(SolidLanguageServer): """ @@ -37,7 +38,7 @@ class Intelephense(SolidLanguageServer): return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> list[str]: + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> list[str]: """ Setup runtime dependencies for Intelephense and return the command to start the server. """ @@ -52,7 +53,7 @@ class Intelephense(SolidLanguageServer): PlatformId.WIN_x64, PlatformId.WIN_arm64, ] - assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy PHP at the moment" + assert platform_id in valid_platforms, f"Platform {platform_id} is not supported by {cls.__name__} at the moment" # Verify both node and npm are installed is_node_installed = shutil.which("node") is not None @@ -74,7 +75,7 @@ class Intelephense(SolidLanguageServer): ) ] ) - deps.install(logger, intelephense_ls_dir) + deps.install(intelephense_ls_dir) assert os.path.exists( intelephense_executable_path @@ -82,19 +83,12 @@ class Intelephense(SolidLanguageServer): return [intelephense_executable_path, "--stdio"] - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Setup runtime dependencies before initializing - intelephense_cmd = self._setup_runtime_dependencies(logger, solidlsp_settings) + intelephense_cmd = self._setup_runtime_dependencies(solidlsp_settings) super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), - "php", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=intelephense_cmd, cwd=repository_root_path), "php", solidlsp_settings ) self.request_id = 0 @@ -105,10 +99,7 @@ class Intelephense(SolidLanguageServer): self._ignored_dirnames = {"node_modules", "cache"} if self._custom_settings.get("ignore_vendor", True): self._ignored_dirnames.add("vendor") - self.logger.log( - f"Ignoring the following directories for PHP projects: {', '.join(sorted(self._ignored_dirnames))}", - logging.INFO, - ) + log.info(f"Ignoring the following directories for PHP projects: {', '.join(sorted(self._ignored_dirnames))}") def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ @@ -157,7 +148,7 @@ class Intelephense(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return @@ -167,19 +158,13 @@ class Intelephense(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting Intelephense server process", logging.INFO) + log.info("Starting Intelephense server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) - self.logger.log( - "After sent initialize params", - logging.INFO, - ) + log.info("After sent initialize params") # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] diff --git a/src/solidlsp/language_servers/jedi_server.py b/src/solidlsp/language_servers/jedi_server.py index f08cd09a..1352cb37 100644 --- a/src/solidlsp/language_servers/jedi_server.py +++ b/src/solidlsp/language_servers/jedi_server.py @@ -11,26 +11,24 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class JediServer(SolidLanguageServer): """ Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a JediServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd="jedi-language-server", cwd=repository_root_path), "python", @@ -169,7 +167,7 @@ class JediServer(SolidLanguageServer): self.completions_available.set() def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("language/status", do_nothing) @@ -180,14 +178,11 @@ class JediServer(SolidLanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - self.logger.log("Starting jedi-language-server server process", logging.INFO) + log.info("Starting jedi-language-server server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" in init_response["capabilities"] diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index 178ac91b..f255a539 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -10,25 +10,20 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class JuliaLanguageServer(SolidLanguageServer): """ Language server implementation for Julia using LanguageServer.jl. """ - def __init__( - self, - config: LanguageServerConfig, - logger: LanguageServerLogger, - repository_root_path: str, - solidlsp_settings: SolidLSPSettings, - ): - julia_executable = self._setup_runtime_dependency(logger) # PASS LOGGER + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + julia_executable = self._setup_runtime_dependency() # PASS LOGGER julia_code = "using LanguageServer; runserver()" julia_ls_cmd: str | list[str] @@ -47,19 +42,14 @@ class JuliaLanguageServer(SolidLanguageServer): f"{shlex.quote(repository_root_path)}" ) - logger.log(f"[JULIA DEBUG] Command: {julia_ls_cmd}", logging.INFO) + log.info(f"[JULIA DEBUG] Command: {julia_ls_cmd}") super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=julia_ls_cmd, cwd=repository_root_path), - "julia", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=julia_ls_cmd, cwd=repository_root_path), "julia", solidlsp_settings ) @staticmethod - def _setup_runtime_dependency(logger: LanguageServerLogger) -> str: + def _setup_runtime_dependency() -> str: """ Check if the Julia runtime is available and return its full path. Raises RuntimeError with a helpful message if the dependency is missing. @@ -94,17 +84,17 @@ class JuliaLanguageServer(SolidLanguageServer): result = subprocess.run(check_cmd, check=False, capture_output=True, text=True, timeout=10) if result.returncode != 0: # LanguageServer.jl not found, install it - JuliaLanguageServer._install_language_server(julia_path, logger) + JuliaLanguageServer._install_language_server(julia_path) except subprocess.TimeoutExpired: # Assume it needs installation - JuliaLanguageServer._install_language_server(julia_path, logger) + JuliaLanguageServer._install_language_server(julia_path) return julia_path @staticmethod - def _install_language_server(julia_path: str, logger: LanguageServerLogger) -> None: + def _install_language_server(julia_path: str) -> None: """Install LanguageServer.jl package.""" - logger.log("LanguageServer.jl not found. Installing... (this may take a minute)", logging.INFO) + log.info("LanguageServer.jl not found. Installing... (this may take a minute)") install_cmd = [julia_path, "-e", 'using Pkg; Pkg.add("LanguageServer")'] @@ -112,7 +102,7 @@ class JuliaLanguageServer(SolidLanguageServer): result = subprocess.run(install_cmd, check=False, capture_output=True, text=True, timeout=300) # 5 minutes for installation if result.returncode == 0: - logger.log("LanguageServer.jl installed successfully!", logging.INFO) + log.info("LanguageServer.jl installed successfully!") else: raise RuntimeError(f"Failed to install LanguageServer.jl: {result.stderr}") except subprocess.TimeoutExpired: @@ -158,20 +148,17 @@ class JuliaLanguageServer(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting LanguageServer.jl server process", logging.INFO) + log.info("Starting LanguageServer.jl server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request to Julia Language Server", - logging.INFO, - ) + log.info("Sending initialize request to Julia Language Server") init_response = self.server.send.initialize(initialize_params) assert "definitionProvider" in init_response["capabilities"] @@ -180,4 +167,4 @@ class JuliaLanguageServer(SolidLanguageServer): self.server.notify.initialized({}) self.completions_available.set() - self.logger.log("Julia Language Server is initialized and ready.", logging.INFO) + log.info("Julia Language Server is initialized and ready.") diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py index c1c6b975..c3f637fb 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -1,5 +1,17 @@ """ Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. + +You can configure the following options in ls_specific_settings (in serena_config.yml): + + ls_specific_settings: + kotlin: + jvm_options: '-Xmx4G' # JVM options for Kotlin Language Server (default: -Xmx4G) + +Example configuration for large projects: + + ls_specific_settings: + kotlin: + jvm_options: '-Xmx8G -XX:+UseG1GC' """ import dataclasses @@ -10,13 +22,18 @@ import stat from typing import cast from solidlsp.ls import SolidLanguageServer -from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformUtils 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__) + +# Default JVM options for Kotlin Language Server +# -Xmx4G: Limit max heap to 4GB to prevent OOM on large projects +DEFAULT_KOTLIN_JVM_OPTIONS = "-Xmx4G" + @dataclasses.dataclass class KotlinRuntimeDependencyPaths: @@ -34,34 +51,38 @@ class KotlinLanguageServer(SolidLanguageServer): Provides Kotlin specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Kotlin. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Kotlin Language Server instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - runtime_dependency_paths = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + runtime_dependency_paths = self._setup_runtime_dependencies(config, solidlsp_settings) self.runtime_dependency_paths = runtime_dependency_paths # Create command to execute the Kotlin Language Server script cmd = [self.runtime_dependency_paths.kotlin_executable_path, "--stdio"] - # Set environment variables including JAVA_HOME - proc_env = {"JAVA_HOME": self.runtime_dependency_paths.java_home_path} + # Get JVM options from settings or use default + jvm_options = DEFAULT_KOTLIN_JVM_OPTIONS + if solidlsp_settings.ls_specific_settings: + kotlin_settings = solidlsp_settings.get_ls_specific_settings(Language.KOTLIN) + custom_jvm_options = kotlin_settings.get("jvm_options", "") + if custom_jvm_options: + jvm_options = custom_jvm_options + log.info(f"Using custom JVM options for Kotlin Language Server: {jvm_options}") + + # Set environment variables including JAVA_HOME and JVM options + # JAVA_TOOL_OPTIONS is automatically picked up by any Java process + proc_env = { + "JAVA_HOME": self.runtime_dependency_paths.java_home_path, + "JAVA_TOOL_OPTIONS": jvm_options, + } super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), - "kotlin", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=cmd, env=proc_env, cwd=repository_root_path), "kotlin", solidlsp_settings ) @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> KotlinRuntimeDependencyPaths: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> KotlinRuntimeDependencyPaths: """ Setup runtime dependencies for Kotlin Language Server and return the paths. """ @@ -130,8 +151,8 @@ class KotlinLanguageServer(SolidLanguageServer): # Download and extract Java if not exists if not os.path.exists(java_path): - logger.log(f"Downloading Java for {platform_id.value}...", logging.INFO) - FileUtils.download_and_extract_archive(logger, java_dependency["url"], java_dir, java_dependency["archiveType"]) + log.info(f"Downloading Java for {platform_id.value}...") + FileUtils.download_and_extract_archive(java_dependency["url"], java_dir, java_dependency["archiveType"]) # Make Java executable if not platform_id.value.startswith("win-"): os.chmod(java_path, 0o755) @@ -149,8 +170,8 @@ class KotlinLanguageServer(SolidLanguageServer): # Download and extract Kotlin Language Server if script doesn't exist if not os.path.exists(kotlin_script): - logger.log("Downloading Kotlin Language Server...", logging.INFO) - FileUtils.download_and_extract_archive(logger, kotlin_dependency["url"], static_dir, kotlin_dependency["archiveType"]) # type: ignore + log.info("Downloading Kotlin Language Server...") + FileUtils.download_and_extract_archive(kotlin_dependency["url"], static_dir, kotlin_dependency["archiveType"]) # type: ignore # Make script executable on Unix platforms if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): @@ -161,7 +182,7 @@ class KotlinLanguageServer(SolidLanguageServer): # Use script file if os.path.exists(kotlin_script): kotlin_executable_path = kotlin_script - logger.log(f"Using Kotlin Language Server script at {kotlin_script}", logging.INFO) + log.info(f"Using Kotlin Language Server script at {kotlin_script}") else: raise FileNotFoundError(f"Kotlin Language Server script not found at {kotlin_script}") @@ -435,7 +456,7 @@ class KotlinLanguageServer(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", do_nothing) self.server.on_notification("language/status", do_nothing) @@ -445,14 +466,11 @@ class KotlinLanguageServer(SolidLanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) - self.logger.log("Starting Kotlin server process", logging.INFO) + log.info("Starting Kotlin server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) capabilities = init_response["capabilities"] diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py index 0ff6b7c4..8802ee87 100644 --- a/src/solidlsp/language_servers/lua_ls.py +++ b/src/solidlsp/language_servers/lua_ls.py @@ -17,11 +17,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class LuaLanguageServer(SolidLanguageServer): """ @@ -154,18 +155,11 @@ class LuaLanguageServer(SolidLanguageServer): return lua_ls_path - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): lua_ls_path = self._setup_runtime_dependency() super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=lua_ls_path, cwd=repository_root_path), - "lua", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=lua_ls_path, cwd=repository_root_path), "lua", solidlsp_settings ) self.server_ready = threading.Event() self.request_id = 0 @@ -263,7 +257,7 @@ class LuaLanguageServer(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return @@ -273,14 +267,11 @@ class LuaLanguageServer(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting Lua Language Server process", logging.INFO) + log.info("Starting Lua Language Server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index 85919113..d799abb2 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -12,13 +12,14 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + class Marksman(SolidLanguageServer): """ @@ -67,9 +68,7 @@ class Marksman(SolidLanguageServer): ) @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> str: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """Setup runtime dependencies for marksman and return the command to start the server.""" deps = cls.runtime_dependencies dependency = deps.get_single_dep_for_current_platform() @@ -77,28 +76,24 @@ class Marksman(SolidLanguageServer): marksman_ls_dir = cls.ls_resources_dir(solidlsp_settings) marksman_executable_path = deps.binary_path(marksman_ls_dir) if not os.path.exists(marksman_executable_path): - logger.log( + log.info( f"Downloading marksman from {dependency.url} to {marksman_ls_dir}", - logging.INFO, ) - deps.install(logger, marksman_ls_dir) + deps.install(marksman_ls_dir) if not os.path.exists(marksman_executable_path): raise FileNotFoundError(f"Download failed? Could not find marksman executable at {marksman_executable_path}") os.chmod(marksman_executable_path, 0o755) return marksman_executable_path - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Marksman instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - marksman_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + marksman_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=f"{marksman_executable_path} server", cwd=repository_root_path), "markdown", @@ -159,7 +154,7 @@ class Marksman(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(_params: dict) -> None: return @@ -169,16 +164,13 @@ class Marksman(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting marksman server process", logging.INFO) + log.info("Starting marksman server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to marksman server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to marksman server and awaiting response") init_response = self.server.send.initialize(initialize_params) - self.logger.log(f"Received initialize response from marksman server: {init_response}", logging.DEBUG) + log.debug(f"Received initialize response from marksman server: {init_response}") # Verify server capabilities assert "textDocumentSync" in init_response["capabilities"] @@ -188,6 +180,6 @@ class Marksman(SolidLanguageServer): self.server.notify.initialized({}) # marksman is typically ready immediately after initialization - self.logger.log("Marksman server initialization complete", logging.INFO) + log.info("Marksman server initialization complete") self.server_ready.set() self.completions_available.set() diff --git a/src/solidlsp/language_servers/nixd_ls.py b/src/solidlsp/language_servers/nixd_ls.py index edf092e3..d3e3e96a 100644 --- a/src/solidlsp/language_servers/nixd_ls.py +++ b/src/solidlsp/language_servers/nixd_ls.py @@ -19,11 +19,12 @@ from overrides import override from solidlsp import ls_types from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class NixLanguageServer(SolidLanguageServer): """ @@ -244,19 +245,10 @@ class NixLanguageServer(SolidLanguageServer): return nixd_path - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): nixd_path = self._setup_runtime_dependency() - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), - "nix", - solidlsp_settings, - ) + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings) self.server_ready = threading.Event() self.request_id = 0 @@ -358,7 +350,7 @@ class NixLanguageServer(SolidLanguageServer): return def window_log_message(msg): - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params): return @@ -368,14 +360,11 @@ class NixLanguageServer(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting nixd server process", logging.INFO) + log.info("Starting nixd server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py index 5c5be862..83bfae10 100644 --- a/src/solidlsp/language_servers/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -14,12 +14,13 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import DotnetVersion, FileUtils, PlatformId, PlatformUtils 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__) + def breadth_first_file_scan(root: str) -> Iterable[str]: """ @@ -59,17 +60,15 @@ class OmniSharp(SolidLanguageServer): Provides C# specific instantiation of the LanguageServer class. Contains various configurations and settings specific to C#. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates an OmniSharp instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - omnisharp_executable_path, dll_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + omnisharp_executable_path, dll_path = self._setup_runtime_dependencies(config, solidlsp_settings) slnfilename = find_least_depth_sln_file(repository_root_path) if slnfilename is None: - logger.log("No *.sln file found in repository", logging.ERROR) + log.error("No *.sln file found in repository") raise SolidLSPException("No SLN file found in repository") cmd = " ".join( @@ -104,9 +103,7 @@ class OmniSharp(SolidLanguageServer): "formattingOptions:indentationSize=4", ] ) - super().__init__( - config, logger, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", solidlsp_settings - ) + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=cmd, cwd=repository_root_path), "csharp", solidlsp_settings) self.server_ready = threading.Event() self.definition_available = threading.Event() @@ -142,9 +139,7 @@ class OmniSharp(SolidLanguageServer): return d @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> tuple[str, str]: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> tuple[str, str]: """ Setup runtime dependencies for OmniSharp. """ @@ -190,7 +185,7 @@ class OmniSharp(SolidLanguageServer): omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "OmniSharp") if not os.path.exists(omnisharp_ls_dir): os.makedirs(omnisharp_ls_dir) - FileUtils.download_and_extract_archive(logger, runtime_dependencies["OmniSharp"]["url"], omnisharp_ls_dir, "zip") + FileUtils.download_and_extract_archive(runtime_dependencies["OmniSharp"]["url"], omnisharp_ls_dir, "zip") omnisharp_executable_path = os.path.join(omnisharp_ls_dir, runtime_dependencies["OmniSharp"]["binaryName"]) assert os.path.exists(omnisharp_executable_path) os.chmod(omnisharp_executable_path, 0o755) @@ -198,7 +193,7 @@ class OmniSharp(SolidLanguageServer): razor_omnisharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "RazorOmnisharp") if not os.path.exists(razor_omnisharp_ls_dir): os.makedirs(razor_omnisharp_ls_dir) - FileUtils.download_and_extract_archive(logger, runtime_dependencies["RazorOmnisharp"]["url"], razor_omnisharp_ls_dir, "zip") + FileUtils.download_and_extract_archive(runtime_dependencies["RazorOmnisharp"]["url"], razor_omnisharp_ls_dir, "zip") razor_omnisharp_dll_path = os.path.join(razor_omnisharp_ls_dir, runtime_dependencies["RazorOmnisharp"]["dll_path"]) assert os.path.exists(razor_omnisharp_dll_path) @@ -358,14 +353,11 @@ class OmniSharp(SolidLanguageServer): self.server.on_notification("experimental/serverStatus", check_experimental_status) self.server.on_request("workspace/configuration", workspace_configuration_handler) - self.logger.log("Starting OmniSharp server process", logging.INFO) + log.info("Starting OmniSharp server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) self.server.notify.initialized({}) with open(os.path.join(os.path.dirname(__file__), "omnisharp", "workspace_did_change_configuration.json"), encoding="utf-8") as f: diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py index 5937b6a4..b7ab42a0 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -15,12 +15,13 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams, InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class PerlLanguageServer(SolidLanguageServer): """ @@ -98,19 +99,12 @@ class PerlLanguageServer(SolidLanguageServer): return "perl -MPerl::LanguageServer -e 'Perl::LanguageServer::run'" - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Setup runtime dependencies before initializing perl_ls_cmd = self._setup_runtime_dependencies() super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), - "perl", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), "perl", solidlsp_settings ) self.request_id = 0 @@ -156,15 +150,15 @@ class PerlLanguageServer(SolidLanguageServer): def register_capability_handler(params: Any) -> None: return - def window_log_message(msg: str) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: Any) -> None: return def workspace_configuration_handler(params: Any) -> Any: """Handle workspace/configuration request from Perl::LanguageServer.""" - self.logger.log(f"Received workspace/configuration request: {params}", logging.INFO) + log.info(f"Received workspace/configuration request: {params}") perl_config = { "perlInc": [self.repository_root_path, "."], @@ -180,18 +174,14 @@ class PerlLanguageServer(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting Perl::LanguageServer process", logging.INFO) + log.info("Starting Perl::LanguageServer process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) - self.logger.log( + log.info( "After sent initialize params", - logging.INFO, ) # Verify server capabilities @@ -213,7 +203,7 @@ class PerlLanguageServer(SolidLanguageServer): } } } - self.logger.log(f"Sending workspace/didChangeConfiguration notification with config: {perl_config}", logging.INFO) + log.info(f"Sending workspace/didChangeConfiguration notification with config: {perl_config}") self.server.notify.workspace_did_change_configuration(perl_config) self.completions_available.set() @@ -221,6 +211,6 @@ class PerlLanguageServer(SolidLanguageServer): # Perl::LanguageServer needs time to index files and resolve cross-file references # Without this delay, requests for definitions/references may return empty results settling_time = 0.5 - self.logger.log(f"Allowing {settling_time} seconds for Perl::LanguageServer to index files...", logging.INFO) + log.info(f"Allowing {settling_time} seconds for Perl::LanguageServer to index files...") time.sleep(settling_time) - self.logger.log("Perl::LanguageServer settling period complete", logging.INFO) + log.info("Perl::LanguageServer settling period complete") diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py new file mode 100644 index 00000000..129ab383 --- /dev/null +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -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") diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index 10cc7886..649dad24 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -13,11 +13,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class PyrightServer(SolidLanguageServer): """ @@ -25,19 +26,14 @@ class PyrightServer(SolidLanguageServer): Contains various configurations and settings specific to Python. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a PyrightServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ super().__init__( config, - logger, repository_root_path, - # Note 1: we can also use `pyright-langserver --stdio` but it requires pyright to be installed with npm - # Note 2: we can also use `bpyright-langserver --stdio` if we ever are unhappy with pyright ProcessLaunchInfo(cmd="python -m pyright.langserver --stdio", cwd=repository_root_path), "python", solidlsp_settings, @@ -140,12 +136,12 @@ class PyrightServer(SolidLanguageServer): Pyright logs "Found X source files" when it finishes scanning the workspace. """ message_text = msg.get("message", "") - self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) + log.info(f"LSP: window/logMessage: {message_text}") # Look for "Found X source files" which indicates workspace scanning is complete # Unfortunately, pyright is unreliable and there seems to be no better way if re.search(r"Found \d+ source files?", message_text): - self.logger.log("Pyright workspace scanning complete", logging.INFO) + log.info("Pyright workspace scanning complete") self.found_source_files = True self.analysis_complete.set() self.completions_available.set() @@ -155,7 +151,7 @@ class PyrightServer(SolidLanguageServer): Also listen for experimental/serverStatus as a backup signal """ if params.get("quiescent") == True: - self.logger.log("Received experimental/serverStatus with quiescent=true", logging.INFO) + log.info("Received experimental/serverStatus with quiescent=true") if not self.found_source_files: self.analysis_complete.set() self.completions_available.set() @@ -170,18 +166,15 @@ class PyrightServer(SolidLanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - self.logger.log("Starting pyright-langserver server process", logging.INFO) + log.info("Starting pyright-langserver server process") self.server.start() # Send proper initialization parameters initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to pyright server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to pyright server and awaiting response") init_response = self.server.send.initialize(initialize_params) - self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO) + log.info(f"Received initialize response from pyright server: {init_response}") # Verify that the server supports our required features assert "textDocumentSync" in init_response["capabilities"] @@ -193,11 +186,11 @@ class PyrightServer(SolidLanguageServer): # Wait for Pyright to complete its initial workspace analysis # This prevents zombie processes by ensuring background tasks finish - self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO) + log.info("Waiting for Pyright to complete initial workspace analysis...") if self.analysis_complete.wait(timeout=5.0): - self.logger.log("Pyright initial analysis complete, server ready", logging.INFO) + log.info("Pyright initial analysis complete, server ready") else: - self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING) + log.warning("Timeout waiting for Pyright analysis completion, proceeding anyway") # Fallback: assume analysis is complete after timeout self.analysis_complete.set() self.completions_available.set() diff --git a/src/solidlsp/language_servers/r_language_server.py b/src/solidlsp/language_servers/r_language_server.py index 467cc144..9802381d 100644 --- a/src/solidlsp/language_servers/r_language_server.py +++ b/src/solidlsp/language_servers/r_language_server.py @@ -9,11 +9,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class RLanguageServer(SolidLanguageServer): """R Language Server implementation using the languageserver R package.""" @@ -57,9 +58,7 @@ class RLanguageServer(SolidLanguageServer): except FileNotFoundError: raise RuntimeError("R is not installed. Please install R from https://www.r-project.org/") - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Check R installation self._check_r_installation() @@ -68,14 +67,7 @@ class RLanguageServer(SolidLanguageServer): # Set specific options to improve parsing stability r_cmd = 'R --vanilla --quiet --slave -e "options(languageserver.debug_mode = FALSE); languageserver::run()"' - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), - "r", - solidlsp_settings, - ) + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), "r", solidlsp_settings) self.server_ready = threading.Event() @staticmethod @@ -132,8 +124,8 @@ class RLanguageServer(SolidLanguageServer): def _start_server(self) -> None: """Start R Language Server process.""" - def window_log_message(msg: str) -> None: - self.logger.log(f"R LSP: window/logMessage: {msg}", logging.INFO) + def window_log_message(msg: dict) -> None: + log.info(f"R LSP: window/logMessage: {msg}") def do_nothing(params: Any) -> None: return @@ -147,13 +139,12 @@ class RLanguageServer(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting R Language Server process", logging.INFO) + log.info("Starting R Language Server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( + log.info( "Sending initialize request to R Language Server", - logging.INFO, ) init_response = self.server.send.initialize(initialize_params) @@ -162,9 +153,9 @@ class RLanguageServer(SolidLanguageServer): capabilities = init_response.get("capabilities", {}) assert "textDocumentSync" in capabilities if "completionProvider" in capabilities: - self.logger.log("R LSP completion provider available", logging.INFO) + log.info("R LSP completion provider available") if "definitionProvider" in capabilities: - self.logger.log("R LSP definition provider available", logging.INFO) + log.info("R LSP definition provider available") self.server.notify.initialized({}) self.completions_available.set() diff --git a/src/solidlsp/language_servers/regal_server.py b/src/solidlsp/language_servers/regal_server.py index eb6abc1d..35c7bee1 100644 --- a/src/solidlsp/language_servers/regal_server.py +++ b/src/solidlsp/language_servers/regal_server.py @@ -9,12 +9,13 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PathUtils 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 RegalLanguageServer(SolidLanguageServer): """ @@ -28,16 +29,13 @@ class RegalLanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [".regal", ".opa"] - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a RegalLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. :param config: Language server configuration - :param logger: Logger instance :param repository_root_path: Path to the repository root :param solidlsp_settings: Settings for solidlsp """ @@ -50,7 +48,6 @@ class RegalLanguageServer(SolidLanguageServer): super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=f"{regal_executable_path} language-server", cwd=repository_root_path), "rego", @@ -108,7 +105,7 @@ class RegalLanguageServer(SolidLanguageServer): return def window_log_message(msg) -> None: # type: ignore[no-untyped-def] - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params) -> None: # type: ignore[no-untyped-def] return @@ -118,13 +115,12 @@ class RegalLanguageServer(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting Regal language server process", logging.INFO) + log.info("Starting Regal language server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( + log.info( "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, ) init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/ruby_lsp.py b/src/solidlsp/language_servers/ruby_lsp.py index 95c9f7b6..744213fe 100644 --- a/src/solidlsp/language_servers/ruby_lsp.py +++ b/src/solidlsp/language_servers/ruby_lsp.py @@ -15,11 +15,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class RubyLsp(SolidLanguageServer): """ @@ -27,21 +28,14 @@ class RubyLsp(SolidLanguageServer): Contains various configurations and settings specific to Ruby with modern LSP features. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a RubyLsp instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - ruby_lsp_executable = self._setup_runtime_dependencies(logger, config, repository_root_path) + ruby_lsp_executable = self._setup_runtime_dependencies(config, repository_root_path) super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd=ruby_lsp_executable, cwd=repository_root_path), - "ruby", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd=ruby_lsp_executable, cwd=repository_root_path), "ruby", solidlsp_settings ) self.analysis_complete = threading.Event() self.service_ready_event = threading.Event() @@ -98,7 +92,7 @@ class RubyLsp(SolidLanguageServer): return shutil.which(executable_name) @staticmethod - def _setup_runtime_dependencies(logger: LanguageServerLogger, config: LanguageServerConfig, repository_root_path: str) -> list[str]: + def _setup_runtime_dependencies(config: LanguageServerConfig, repository_root_path: str) -> list[str]: """ Setup runtime dependencies for ruby-lsp and return the command list to start the server. Installation strategy: Bundler project > global ruby-lsp > gem install ruby-lsp @@ -125,24 +119,23 @@ class RubyLsp(SolidLanguageServer): if use_rbenv: ruby_cmd = ["rbenv", "exec", "ruby"] bundle_cmd = ["rbenv", "exec", "bundle"] - logger.log(f"Using rbenv-managed Ruby (found {ruby_version_file})", logging.INFO) + log.info(f"Using rbenv-managed Ruby (found {ruby_version_file})") else: ruby_cmd = ["ruby"] bundle_cmd = ["bundle"] if os.path.exists(ruby_version_file): - logger.log( + log.warning( f"Found {ruby_version_file} but rbenv is not installed. " "Using system Ruby. Consider installing rbenv for better version management: https://github.com/rbenv/rbenv", - logging.WARNING, ) else: - logger.log("No .ruby-version file found, using system Ruby", logging.INFO) + log.info("No .ruby-version file found, using system Ruby") # Check if Ruby is installed try: result = subprocess.run(ruby_cmd + ["--version"], check=True, capture_output=True, cwd=repository_root_path, text=True) ruby_version = result.stdout.strip() - logger.log(f"Ruby version: {ruby_version}", logging.INFO) + log.info(f"Ruby version: {ruby_version}") # Extract version number for compatibility checks import re @@ -151,7 +144,7 @@ class RubyLsp(SolidLanguageServer): if version_match: major, minor, patch = map(int, version_match.groups()) if major < 2 or (major == 2 and minor < 6): - logger.log(f"Warning: Ruby {major}.{minor}.{patch} detected. ruby-lsp works best with Ruby 2.6+", logging.WARNING) + log.warning(f"Warning: Ruby {major}.{minor}.{patch} detected. ruby-lsp works best with Ruby 2.6+") except subprocess.CalledProcessError as e: error_msg = e.stderr if isinstance(e.stderr, str) else e.stderr.decode() if e.stderr else "Unknown error" @@ -173,7 +166,7 @@ class RubyLsp(SolidLanguageServer): is_bundler_project = os.path.exists(gemfile_path) if is_bundler_project: - logger.log("Detected Bundler project (Gemfile found)", logging.INFO) + log.info("Detected Bundler project (Gemfile found)") # Check if bundle command is available using Windows-compatible search bundle_path = RubyLsp._find_executable_with_extensions(bundle_cmd[0] if len(bundle_cmd) == 1 else "bundle") @@ -190,9 +183,8 @@ class RubyLsp(SolidLanguageServer): break if not bundle_path: - logger.log( + log.warning( "Bundler project detected but 'bundle' command not found. Falling back to global ruby-lsp installation.", - logging.WARNING, ) else: # Check if ruby-lsp is in Gemfile.lock @@ -203,29 +195,28 @@ class RubyLsp(SolidLanguageServer): content = f.read() ruby_lsp_in_bundle = "ruby-lsp" in content.lower() except Exception as e: - logger.log(f"Warning: Could not read Gemfile.lock: {e}", logging.WARNING) + log.warning(f"Warning: Could not read Gemfile.lock: {e}") if ruby_lsp_in_bundle: - logger.log("Found ruby-lsp in Gemfile.lock", logging.INFO) + log.info("Found ruby-lsp in Gemfile.lock") return bundle_cmd + ["exec", "ruby-lsp"] else: - logger.log( + log.info( "ruby-lsp not found in Gemfile.lock. Consider adding 'gem \"ruby-lsp\"' to your Gemfile for better compatibility.", - logging.INFO, ) # Fall through to global installation check # Check if ruby-lsp is available globally using Windows-compatible search ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp") if ruby_lsp_path: - logger.log(f"Found ruby-lsp at: {ruby_lsp_path}", logging.INFO) + log.info(f"Found ruby-lsp at: {ruby_lsp_path}") return [ruby_lsp_path] # Try to install ruby-lsp globally - logger.log("ruby-lsp not found, attempting to install globally...", logging.INFO) + log.info("ruby-lsp not found, attempting to install globally...") try: subprocess.run(["gem", "install", "ruby-lsp"], check=True, capture_output=True, cwd=repository_root_path) - logger.log("Successfully installed ruby-lsp globally", logging.INFO) + log.info("Successfully installed ruby-lsp globally") # Find the newly installed ruby-lsp executable ruby_lsp_path = RubyLsp._find_executable_with_extensions("ruby-lsp") return [ruby_lsp_path] if ruby_lsp_path else ["ruby-lsp"] @@ -351,13 +342,13 @@ class RubyLsp(SolidLanguageServer): def register_capability_handler(params: dict) -> None: assert "registrations" in params for registration in params["registrations"]: - self.logger.log(f"Registered capability: {registration['method']}", logging.INFO) + log.info(f"Registered capability: {registration['method']}") return def lang_status_handler(params: dict) -> None: - self.logger.log(f"LSP: language/status: {params}", logging.INFO) + log.info(f"LSP: language/status: {params}") if params.get("type") == "ready": - self.logger.log("ruby-lsp service is ready.", logging.INFO) + log.info("ruby-lsp service is ready.") self.analysis_complete.set() self.completions_available.set() @@ -368,36 +359,36 @@ class RubyLsp(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def progress_handler(params: dict) -> None: # ruby-lsp sends progress notifications during indexing - self.logger.log(f"LSP: $/progress: {params}", logging.DEBUG) + log.debug(f"LSP: $/progress: {params}") if "value" in params: value = params["value"] # Check for completion indicators if value.get("kind") == "end": - self.logger.log("ruby-lsp indexing complete ($/progress end)", logging.INFO) + log.info("ruby-lsp indexing complete ($/progress end)") self.analysis_complete.set() self.completions_available.set() elif value.get("kind") == "begin": - self.logger.log("ruby-lsp indexing started ($/progress begin)", logging.INFO) + log.info("ruby-lsp indexing started ($/progress begin)") elif "percentage" in value: percentage = value.get("percentage", 0) - self.logger.log(f"ruby-lsp indexing progress: {percentage}%", logging.DEBUG) + log.debug(f"ruby-lsp indexing progress: {percentage}%") # Handle direct progress format (fallback) elif "token" in params and "value" in params: token = params.get("token") if isinstance(token, str) and "indexing" in token.lower(): value = params.get("value", {}) if value.get("kind") == "end" or value.get("percentage") == 100: - self.logger.log("ruby-lsp indexing complete (token progress)", logging.INFO) + log.info("ruby-lsp indexing complete (token progress)") self.analysis_complete.set() self.completions_available.set() def window_work_done_progress_create(params: dict) -> dict: """Handle workDoneProgress/create requests from ruby-lsp""" - self.logger.log(f"LSP: window/workDoneProgress/create: {params}", logging.DEBUG) + log.debug(f"LSP: window/workDoneProgress/create: {params}") return {} self.server.on_request("client/registerCapability", register_capability_handler) @@ -408,17 +399,14 @@ class RubyLsp(SolidLanguageServer): self.server.on_request("window/workDoneProgress/create", window_work_done_progress_create) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting ruby-lsp server process", logging.INFO) + log.info("Starting ruby-lsp server process") self.server.start() initialize_params = self._get_initialize_params() - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - self.logger.log(f"Sending init params: {json.dumps(initialize_params, indent=4)}", logging.INFO) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") init_response = self.server.send.initialize(initialize_params) - self.logger.log(f"Received init response: {init_response}", logging.INFO) + log.info(f"Received init response: {init_response}") # Verify expected capabilities # Note: ruby-lsp may return textDocumentSync in different formats (number or object) @@ -434,11 +422,11 @@ class RubyLsp(SolidLanguageServer): self.server.notify.initialized({}) # Wait for ruby-lsp to complete its initial indexing # ruby-lsp has fast indexing - self.logger.log("Waiting for ruby-lsp to complete initial indexing...", logging.INFO) + log.info("Waiting for ruby-lsp to complete initial indexing...") if self.analysis_complete.wait(timeout=30.0): - self.logger.log("ruby-lsp initial indexing complete, server ready", logging.INFO) + log.info("ruby-lsp initial indexing complete, server ready") else: - self.logger.log("Timeout waiting for ruby-lsp indexing completion, proceeding anyway", logging.WARNING) + log.warning("Timeout waiting for ruby-lsp indexing completion, proceeding anyway") # Fallback: assume indexing is complete after timeout self.analysis_complete.set() self.completions_available.set() @@ -472,7 +460,7 @@ class RubyLsp(SolidLanguageServer): for cap in important_capabilities: if cap in capabilities: - self.logger.log(f"ruby-lsp {cap}: available", logging.DEBUG) + log.debug(f"ruby-lsp {cap}: available") # Signal that the service is ready self.service_ready_event.set() diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index aa5617c5..7791b3f1 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -13,11 +13,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class RustAnalyzer(SolidLanguageServer): """ @@ -95,18 +96,15 @@ class RustAnalyzer(SolidLanguageServer): return path - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a RustAnalyzer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ rustanalyzer_executable_path = self._ensure_rust_analyzer_installed() - logger.log(f"Using rust-analyzer at: {rustanalyzer_executable_path}", logging.INFO) + log.info(f"Using rust-analyzer at: {rustanalyzer_executable_path}") super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=rustanalyzer_executable_path, cwd=repository_root_path), "rust", @@ -626,7 +624,7 @@ class RustAnalyzer(SolidLanguageServer): self.server_ready.set() def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) @@ -637,14 +635,11 @@ class RustAnalyzer(SolidLanguageServer): self.server.on_notification("language/actionableNotification", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - self.logger.log("Starting RustAnalyzer server process", logging.INFO) + log.info("Starting RustAnalyzer server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore assert "completionProvider" in init_response["capabilities"] diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index 5911c453..e835c36f 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -12,7 +12,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -22,21 +21,21 @@ if not PlatformUtils.get_platform_id().value.startswith("win"): pass +log = logging.getLogger(__name__) + + class ScalaLanguageServer(SolidLanguageServer): """ Provides Scala specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Scala. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a ScalaLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - scala_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + scala_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=scala_lsp_executable_path, cwd=repository_root_path), config.code_language.value, @@ -52,26 +51,27 @@ class ScalaLanguageServer(SolidLanguageServer): ] @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> list[str]: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: """ Setup runtime dependencies for Scala Language Server and return the command to start the server. """ assert shutil.which("java") is not None, "JDK is not installed or not in PATH." + metals_version = "1.6.4" + metals_home = os.path.join(cls.ls_resources_dir(solidlsp_settings), "metals-lsp") os.makedirs(metals_home, exist_ok=True) - metals_executable = os.path.join(metals_home, "metals") + metals_executable = os.path.join(metals_home, metals_version, "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: - logger.log("'cs' command not found. Trying to install it using 'coursier'.", logging.INFO) + assert coursier_command_path is not None + log.info("'cs' command not found. Trying to install it using 'coursier'.") try: - logger.log("Running 'coursier setup --yes' to install 'cs'...", logging.INFO) + log.info("Running 'coursier setup --yes' to install 'cs'...") subprocess.run([coursier_command_path, "setup", "--yes"], check=True, capture_output=True, text=True) except subprocess.CalledProcessError as e: raise RuntimeError(f"Failed to set up 'cs' command with 'coursier setup'. Stderr: {e.stderr}") @@ -81,10 +81,11 @@ class ScalaLanguageServer(SolidLanguageServer): raise RuntimeError( "'cs' command not found after running 'coursier setup'. Please check your PATH or install it manually." ) - logger.log("'cs' command installed successfully.", logging.INFO) + log.info("'cs' command installed successfully.") - logger.log(f"metals executable not found at {metals_executable}, bootstrapping...", logging.INFO) - artifact = "org.scalameta:metals_2.13:1.6.2" + log.info(f"metals executable not found at {metals_executable}, bootstrapping...") + subprocess.run(["mkdir", "-p", os.path.join(metals_home, metals_version)], check=True) + artifact = f"org.scalameta:metals_2.13:{metals_version}" cmd = [ cs_command_path, "bootstrap", @@ -103,9 +104,9 @@ class ScalaLanguageServer(SolidLanguageServer): metals_executable, "-f", ] - logger.log("Bootstrapping metals...", logging.INFO) + log.info("Bootstrapping metals...") subprocess.run(cmd, cwd=metals_home, check=True) - logger.log("Bootstrapping metals finished.", logging.INFO) + log.info("Bootstrapping metals finished.") return [metals_executable] @staticmethod @@ -159,13 +160,10 @@ class ScalaLanguageServer(SolidLanguageServer): """ Starts the Scala Language Server """ - self.logger.log("Starting Scala server process", logging.INFO) + log.info("Starting Scala server process") self.server.start() - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") initialize_params = self._get_initialize_params(self.repository_root_path) self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/solargraph.py b/src/solidlsp/language_servers/solargraph.py index 61249cde..4915f730 100644 --- a/src/solidlsp/language_servers/solargraph.py +++ b/src/solidlsp/language_servers/solargraph.py @@ -16,11 +16,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class Solargraph(SolidLanguageServer): """ @@ -28,17 +29,14 @@ class Solargraph(SolidLanguageServer): Contains various configurations and settings specific to Ruby. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a Solargraph instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - solargraph_executable_path = self._setup_runtime_dependencies(logger, config, repository_root_path) + solargraph_executable_path = self._setup_runtime_dependencies(config, repository_root_path) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=f"{solargraph_executable_path} stdio", cwd=repository_root_path), "ruby", @@ -73,7 +71,7 @@ class Solargraph(SolidLanguageServer): return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs @staticmethod - def _setup_runtime_dependencies(logger: LanguageServerLogger, config: LanguageServerConfig, repository_root_path: str) -> str: + def _setup_runtime_dependencies(config: LanguageServerConfig, repository_root_path: str) -> str: """ Setup runtime dependencies for Solargraph and return the command to start the server. """ @@ -81,14 +79,14 @@ class Solargraph(SolidLanguageServer): try: result = subprocess.run(["ruby", "--version"], check=True, capture_output=True, cwd=repository_root_path, text=True) ruby_version = result.stdout.strip() - logger.log(f"Ruby version: {ruby_version}", logging.INFO) + log.info(f"Ruby version: {ruby_version}") # Extract version number for compatibility checks version_match = re.search(r"ruby (\d+)\.(\d+)\.(\d+)", ruby_version) if version_match: major, minor, patch = map(int, version_match.groups()) if major < 2 or (major == 2 and minor < 6): - logger.log(f"Warning: Ruby {major}.{minor}.{patch} detected. Solargraph works best with Ruby 2.6+", logging.WARNING) + log.warning(f"Warning: Ruby {major}.{minor}.{patch} detected. Solargraph works best with Ruby 2.6+") except subprocess.CalledProcessError as e: error_msg = e.stderr.decode() if e.stderr else "Unknown error" @@ -124,7 +122,7 @@ class Solargraph(SolidLanguageServer): is_bundler_project = os.path.exists(gemfile_path) if is_bundler_project: - logger.log("Detected Bundler project (Gemfile found)", logging.INFO) + log.info("Detected Bundler project (Gemfile found)") # Check if bundle command is available bundle_path = find_executable_with_extensions("bundle") @@ -155,15 +153,14 @@ class Solargraph(SolidLanguageServer): content = f.read() solargraph_in_bundle = "solargraph" in content.lower() except Exception as e: - logger.log(f"Warning: Could not read Gemfile.lock: {e}", logging.WARNING) + log.warning(f"Warning: Could not read Gemfile.lock: {e}") if solargraph_in_bundle: - logger.log("Found solargraph in Gemfile.lock", logging.INFO) + log.info("Found solargraph in Gemfile.lock") return f"{bundle_path} exec solargraph" else: - logger.log( + log.warning( "solargraph not found in Gemfile.lock. Please add 'gem \"solargraph\"' to your Gemfile and run 'bundle install'", - logging.WARNING, ) # Fall through to global installation check @@ -171,7 +168,7 @@ class Solargraph(SolidLanguageServer): # First, try to find solargraph in PATH (includes asdf shims) with Windows support solargraph_path = find_executable_with_extensions("solargraph") if solargraph_path: - logger.log(f"Found solargraph at: {solargraph_path}", logging.INFO) + log.info(f"Found solargraph at: {solargraph_path}") return solargraph_path # Fallback to gem exec (for non-Bundler projects or when global solargraph not found) @@ -191,7 +188,7 @@ class Solargraph(SolidLanguageServer): ["gem", "list", "^solargraph$", "-i"], check=False, capture_output=True, text=True, cwd=repository_root_path ) if result.stdout.strip() == "false": - logger.log("Installing Solargraph...", logging.INFO) + log.info("Installing Solargraph...") subprocess.run(dependency["installCommand"].split(), check=True, capture_output=True, cwd=repository_root_path) return "gem exec solargraph" @@ -319,9 +316,9 @@ class Solargraph(SolidLanguageServer): return def lang_status_handler(params: dict) -> None: - self.logger.log(f"LSP: language/status: {params}", logging.INFO) + log.info(f"LSP: language/status: {params}") if params.get("type") == "ServiceReady" and params.get("message") == "Service is ready.": - self.logger.log("Solargraph service is ready.", logging.INFO) + log.info("Solargraph service is ready.") self.analysis_complete.set() self.completions_available.set() @@ -332,7 +329,7 @@ class Solargraph(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("language/status", lang_status_handler) @@ -342,17 +339,14 @@ class Solargraph(SolidLanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("language/actionableNotification", do_nothing) - self.logger.log("Starting solargraph server process", logging.INFO) + log.info("Starting solargraph server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) - self.logger.log(f"Sending init params: {json.dumps(initialize_params, indent=4)}", logging.INFO) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") init_response = self.server.send.initialize(initialize_params) - self.logger.log(f"Received init response: {init_response}", logging.INFO) + log.info(f"Received init response: {init_response}") assert init_response["capabilities"]["textDocumentSync"] == 2 assert "completionProvider" in init_response["capabilities"] assert init_response["capabilities"]["completionProvider"] == { @@ -363,11 +357,11 @@ class Solargraph(SolidLanguageServer): # Wait for Solargraph to complete its initial workspace analysis # This prevents issues by ensuring background tasks finish - self.logger.log("Waiting for Solargraph to complete initial workspace analysis...", logging.INFO) + log.info("Waiting for Solargraph to complete initial workspace analysis...") if self.analysis_complete.wait(timeout=60.0): - self.logger.log("Solargraph initial analysis complete, server ready", logging.INFO) + log.info("Solargraph initial analysis complete, server ready") else: - self.logger.log("Timeout waiting for Solargraph analysis completion, proceeding anyway", logging.WARNING) + log.warning("Timeout waiting for Solargraph analysis completion, proceeding anyway") # Fallback: assume analysis is complete after timeout self.analysis_complete.set() self.completions_available.set() diff --git a/src/solidlsp/language_servers/sourcekit_lsp.py b/src/solidlsp/language_servers/sourcekit_lsp.py index d21eac4d..ad004d5d 100644 --- a/src/solidlsp/language_servers/sourcekit_lsp.py +++ b/src/solidlsp/language_servers/sourcekit_lsp.py @@ -10,11 +10,12 @@ from overrides import override from solidlsp import ls_types from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class SourceKitLSP(SolidLanguageServer): """ @@ -45,19 +46,12 @@ class SourceKitLSP(SolidLanguageServer): "And make sure it is available on your PATH." ) from e - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): sourcekit_version = self._get_sourcekit_lsp_version() - logger.log(f"Starting sourcekit lsp with version: {sourcekit_version}", logging.INFO) + log.info(f"Starting sourcekit lsp with version: {sourcekit_version}") super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd="sourcekit-lsp", cwd=repository_root_path), - "swift", - solidlsp_settings, + config, repository_root_path, ProcessLaunchInfo(cmd="sourcekit-lsp", cwd=repository_root_path), "swift", solidlsp_settings ) self.server_ready = threading.Event() self.request_id = 0 @@ -314,7 +308,7 @@ class SourceKitLSP(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(_params: dict) -> None: return @@ -324,18 +318,15 @@ class SourceKitLSP(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting sourcekit-lsp server process", logging.INFO) + log.info("Starting sourcekit-lsp server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) capabilities = init_response["capabilities"] - self.logger.log(f"SourceKit LSP capabilities: {list(capabilities.keys())}", logging.INFO) + log.info(f"SourceKit LSP capabilities: {list(capabilities.keys())}") assert "textDocumentSync" in capabilities, "textDocumentSync capability missing" assert "definitionProvider" in capabilities, "definitionProvider capability missing" @@ -366,10 +357,7 @@ class SourceKitLSP(SolidLanguageServer): # Fallback if initialization timestamp is missing remaining_delay = 15 if os.getenv("CI") else 5 - self.logger.log( - f"Sleeping {remaining_delay:.1f}s before requesting references for the first time (CI needs extra indexing time)", - logging.INFO, - ) + log.info(f"Sleeping {remaining_delay:.1f}s before requesting references for the first time (CI needs extra indexing time)") time.sleep(remaining_delay) self._did_sleep_before_requesting_references = True @@ -378,7 +366,7 @@ class SourceKitLSP(SolidLanguageServer): # In CI, if no references found, retry once after additional delay if os.getenv("CI") and not references: - self.logger.log("No references found in CI - retrying after additional 5s delay", logging.INFO) + log.info("No references found in CI - retrying after additional 5s delay") time.sleep(5) references = super().request_references(relative_file_path, line, column) diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py new file mode 100644 index 00000000..dcc418ec --- /dev/null +++ b/src/solidlsp/language_servers/taplo_server.py @@ -0,0 +1,316 @@ +""" +Provides TOML specific instantiation of the LanguageServer class using Taplo. +Contains various configurations and settings specific to TOML files. +""" + +import gzip +import hashlib +import logging +import os +import platform +import shutil +import socket +import stat +import threading +import urllib.request +from typing import Any + +# Download timeout in seconds (prevents indefinite hangs) +DOWNLOAD_TIMEOUT_SECONDS = 120 + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import PathUtils +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__) + +# Taplo release version and download URLs +TAPLO_VERSION = "0.10.0" +TAPLO_DOWNLOAD_BASE = f"https://github.com/tamasfe/taplo/releases/download/{TAPLO_VERSION}" + +# SHA256 checksums for Taplo releases (verified from official GitHub releases) +# Source: https://github.com/tamasfe/taplo/releases/tag/0.10.0 +# To update: download each release file and run: sha256sum +TAPLO_SHA256_CHECKSUMS: dict[str, str] = { + "taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9", + "taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0", + "taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2", + "taplo-darwin-aarch64.gz": "713734314c3e71894b9e77513c5349835eefbd52908445a0d73b0c7dc469347d", + "taplo-linux-x86_64.gz": "8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156", + "taplo-linux-aarch64.gz": "033681d01eec8376c3fd38fa3703c79316f5e14bb013d859943b60a07bccdcc3", + "taplo-linux-armv7.gz": "6b728896afe2573522f38b8e668b1ff40eb5928fd9d6d0c253ecae508274d417", +} + + +def _verify_sha256(file_path: str, expected_hash: str) -> bool: + """Verify SHA256 checksum of a downloaded file.""" + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + actual_hash = sha256_hash.hexdigest() + return actual_hash.lower() == expected_hash.lower() + + +def _get_taplo_download_url() -> tuple[str, str]: + """ + Get the appropriate Taplo download URL for the current platform. + + Returns: + Tuple of (download_url, executable_name) + + """ + system = platform.system().lower() + machine = platform.machine().lower() + + # Map machine architecture to Taplo naming convention + arch_map = { + "x86_64": "x86_64", + "amd64": "x86_64", + "x86": "x86", + "i386": "x86", + "i686": "x86", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "armv7", + } + + arch = arch_map.get(machine, "x86_64") # Default to x86_64 + + if system == "windows": + filename = f"taplo-windows-{arch}.zip" + executable = "taplo.exe" + elif system == "darwin": + filename = f"taplo-darwin-{arch}.gz" + executable = "taplo" + else: # Linux and others + filename = f"taplo-linux-{arch}.gz" + executable = "taplo" + + return f"{TAPLO_DOWNLOAD_BASE}/{filename}", executable + + +class TaploServer(SolidLanguageServer): + """ + Provides TOML specific instantiation of the LanguageServer class using Taplo. + Taplo is a TOML toolkit with LSP support for validation, formatting, and schema support. + """ + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify Taplo stderr output to avoid false-positive errors.""" + line_lower = line.lower() + + # Known informational messages from Taplo + if any( + [ + "schema" in line_lower and "not found" in line_lower, + "warning" in line_lower, + ] + ): + return logging.DEBUG + + return SolidLanguageServer._determine_log_level(line) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TaploServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + taplo_executable_path = self._setup_runtime_dependencies(solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=f"{taplo_executable_path} lsp stdio", cwd=repository_root_path), + "toml", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for Taplo and return the command to start the server. + """ + # First check if taplo is already installed system-wide + system_taplo = shutil.which("taplo") + if system_taplo: + log.info(f"Using system-installed Taplo at: {system_taplo}") + return system_taplo + + # Setup local installation directory + taplo_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "taplo") + os.makedirs(taplo_dir, exist_ok=True) + + _, executable_name = _get_taplo_download_url() + taplo_executable = os.path.join(taplo_dir, executable_name) + + if os.path.exists(taplo_executable) and os.access(taplo_executable, os.X_OK): + log.info(f"Using cached Taplo at: {taplo_executable}") + return taplo_executable + + # Download and install Taplo + log.info(f"Taplo not found. Downloading version {TAPLO_VERSION}...") + cls._download_taplo(taplo_dir, taplo_executable) + + if not os.path.exists(taplo_executable): + raise FileNotFoundError( + f"Taplo executable not found at {taplo_executable}. " + "Installation may have failed. Try installing manually: cargo install taplo-cli --locked" + ) + + return taplo_executable + + @classmethod + def _download_taplo(cls, install_dir: str, executable_path: str) -> None: + """Download and extract Taplo binary with SHA256 verification.""" + # TODO: consider using existing download utilities in SolidLSP instead of the custom logic here + download_url, _ = _get_taplo_download_url() + archive_filename = os.path.basename(download_url) + + try: + log.info(f"Downloading Taplo from: {download_url}") + archive_path = os.path.join(install_dir, archive_filename) + + # Download the archive with timeout to prevent indefinite hangs + old_timeout = socket.getdefaulttimeout() + try: + socket.setdefaulttimeout(DOWNLOAD_TIMEOUT_SECONDS) + urllib.request.urlretrieve(download_url, archive_path) + finally: + socket.setdefaulttimeout(old_timeout) + + # Verify SHA256 checksum + expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename) + if expected_hash: + if not _verify_sha256(archive_path, expected_hash): + os.remove(archive_path) + raise RuntimeError( + f"SHA256 checksum verification failed for {archive_filename}. " + "The downloaded file may be corrupted or tampered with. " + "Try installing manually: cargo install taplo-cli --locked" + ) + log.info(f"SHA256 checksum verified for {archive_filename}") + else: + log.warning( + f"No SHA256 checksum available for {archive_filename}. " + "Skipping verification - consider installing manually: cargo install taplo-cli --locked" + ) + + # Extract based on format + if archive_path.endswith(".gz") and not archive_path.endswith(".tar.gz"): + # Single file gzip + with gzip.open(archive_path, "rb") as f_in: + with open(executable_path, "wb") as f_out: + f_out.write(f_in.read()) + elif archive_path.endswith(".zip"): + import zipfile + + with zipfile.ZipFile(archive_path, "r") as zip_ref: + # Security: Validate paths to prevent zip slip vulnerability + for member in zip_ref.namelist(): + member_path = os.path.normpath(os.path.join(install_dir, member)) + if not member_path.startswith(os.path.normpath(install_dir)): + raise RuntimeError(f"Zip slip detected: {member} attempts to escape install directory") + zip_ref.extractall(install_dir) + + # Make executable on Unix systems + if os.name != "nt": + os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + # Clean up archive + os.remove(archive_path) + log.info(f"Taplo installed successfully at: {executable_path}") + + except Exception as e: + log.error(f"Failed to download Taplo: {e}") + raise RuntimeError( + f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked" + ) from e + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Taplo Language Server. + """ + root_uri = PathUtils.path_to_uri(repository_absolute_path) + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """ + Starts the Taplo Language Server and initializes it. + """ + + def register_capability_handler(params: Any) -> None: + return + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + 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) + + log.info("Starting Taplo server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request to Taplo server") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Taplo: {init_response}") + + # Verify document symbol support + capabilities = init_response.get("capabilities", {}) + if capabilities.get("documentSymbolProvider"): + log.info("Taplo server supports document symbols") + else: + log.warning("Taplo server may have limited document symbol support") + + self.server.notify.initialized({}) + + log.info("Taplo server initialization complete") + self.server_ready.set() + self.completions_available.set() + + def is_ignored_dirname(self, dirname: str) -> bool: + """Define TOML-specific directories to ignore.""" + return super().is_ignored_dirname(dirname) or dirname in ["target", ".cargo", "node_modules"] diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index 66fd01a1..2b54c0fa 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -8,7 +8,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PathUtils, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -16,6 +15,8 @@ from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + class TerraformLS(SolidLanguageServer): """ @@ -55,13 +56,13 @@ class TerraformLS(SolidLanguageServer): return SolidLanguageServer._determine_log_level(line) @staticmethod - def _ensure_tf_command_available(logger: LanguageServerLogger) -> None: - logger.log("Starting terraform version detection...", logging.DEBUG) + def _ensure_tf_command_available() -> None: + log.debug("Starting terraform version detection...") # 1. Try to find terraform using shutil.which terraform_cmd = shutil.which("terraform") if terraform_cmd is not None: - logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) + log.debug(f"Found terraform via shutil.which: {terraform_cmd}") return # TODO: is this needed? @@ -69,7 +70,7 @@ class TerraformLS(SolidLanguageServer): if not terraform_cmd: terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH") if terraform_cli_path: - logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) + log.debug(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}") # TODO: use binary name from runtime dependencies if we keep this code if os.name == "nt": terraform_binary = os.path.join(terraform_cli_path, "terraform.exe") @@ -77,7 +78,7 @@ class TerraformLS(SolidLanguageServer): terraform_binary = os.path.join(terraform_cli_path, "terraform") if os.path.exists(terraform_binary): terraform_cmd = terraform_binary - logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) + log.debug(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}") return raise RuntimeError( @@ -86,12 +87,12 @@ class TerraformLS(SolidLanguageServer): ) @classmethod - def _setup_runtime_dependencies(cls, logger: LanguageServerLogger, solidlsp_settings: SolidLSPSettings) -> str: + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for terraform-ls. Downloads and installs terraform-ls if not already present. """ - cls._ensure_tf_command_available(logger) + cls._ensure_tf_command_available() platform_id = PlatformUtils.get_platform_id() deps = RuntimeDependencyCollection( [ @@ -141,8 +142,8 @@ class TerraformLS(SolidLanguageServer): terraform_ls_executable_path = deps.binary_path(cls.ls_resources_dir(solidlsp_settings)) if not os.path.exists(terraform_ls_executable_path): - logger.log(f"Downloading terraform-ls from {dependency.url}", logging.INFO) - deps.install(logger, cls.ls_resources_dir(solidlsp_settings)) + log.info(f"Downloading terraform-ls from {dependency.url}") + deps.install(cls.ls_resources_dir(solidlsp_settings)) assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" @@ -152,17 +153,14 @@ class TerraformLS(SolidLanguageServer): return terraform_ls_executable_path - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - terraform_ls_executable_path = self._setup_runtime_dependencies(logger, solidlsp_settings) + terraform_ls_executable_path = self._setup_runtime_dependencies(solidlsp_settings) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path), "terraform", @@ -211,7 +209,7 @@ class TerraformLS(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return @@ -221,14 +219,11 @@ class TerraformLS(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting terraform-ls server process", logging.INFO) + log.info("Starting terraform-ls server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 2ebcb869..4734da45 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -12,9 +12,9 @@ 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_logger import LanguageServerLogger from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -22,6 +22,8 @@ from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + # Platform-specific imports if os.name != "nt": # Unix-like systems import pwd @@ -38,21 +40,41 @@ 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, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a TypeScriptLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - ts_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + ts_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=ts_lsp_executable_path, cwd=repository_root_path), "typescript", @@ -76,9 +98,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): return SolidLanguageServer._determine_log_level(line) @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> list[str]: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: """ Setup runtime dependencies for TypeScript Language Server and return the command to start the server. """ @@ -95,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", ), ] @@ -118,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): - logger.log(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...", logging.INFO) - with LogTime("Installation of TypeScript language server dependencies", logger=logger.logger): - deps.install(logger, tsserver_ls_dir) + 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( @@ -138,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. """ @@ -160,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, @@ -210,7 +255,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def check_experimental_status(params: dict) -> None: """ @@ -227,13 +272,12 @@ class TypeScriptLanguageServer(SolidLanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - self.logger.log("Starting TypeScript server process", logging.INFO) + log.info("Starting TypeScript server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( + log.info( "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, ) init_response = self.server.send.initialize(initialize_params) @@ -247,9 +291,9 @@ class TypeScriptLanguageServer(SolidLanguageServer): self.server.notify.initialized({}) if self.server_ready.wait(timeout=1.0): - self.logger.log("TypeScript server is ready", logging.INFO) + log.info("TypeScript server is ready") else: - self.logger.log("Timeout waiting for TypeScript server to become ready, proceeding anyway", logging.INFO) + log.info("Timeout waiting for TypeScript server to become ready, proceeding anyway") # Fallback: assume server is ready after timeout self.server_ready.set() self.completions_available.set() @@ -257,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) diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index 51922e70..21320374 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -15,7 +15,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -23,6 +22,8 @@ from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection +log = logging.getLogger(__name__) + class VtsLanguageServer(SolidLanguageServer): """ @@ -30,16 +31,13 @@ class VtsLanguageServer(SolidLanguageServer): Contains various configurations and settings specific to TypeScript via vtsls wrapper. """ - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a VtsLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - vts_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + vts_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=vts_lsp_executable_path, cwd=repository_root_path), "typescript", @@ -58,9 +56,7 @@ class VtsLanguageServer(SolidLanguageServer): ] @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> str: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for VTS Language Server and return the command to start the server. """ @@ -99,7 +95,7 @@ class VtsLanguageServer(SolidLanguageServer): # Install vtsls if not already installed if not os.path.exists(vts_ls_dir): os.makedirs(vts_ls_dir, exist_ok=True) - deps.install(logger, vts_ls_dir) + deps.install(vts_ls_dir) vts_executable_path = os.path.join(vts_ls_dir, "node_modules", ".bin", "vtsls") @@ -182,7 +178,7 @@ class VtsLanguageServer(SolidLanguageServer): return def window_log_message(msg: dict) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + log.info(f"LSP: window/logMessage: {msg}") def check_experimental_status(params: dict) -> None: """ @@ -200,33 +196,30 @@ class VtsLanguageServer(SolidLanguageServer): self.server.on_notification("textDocument/publishDiagnostics", do_nothing) self.server.on_notification("experimental/serverStatus", check_experimental_status) - self.logger.log("Starting VTS server process", logging.INFO) + log.info("Starting VTS server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # VTS-specific capability checks # Be more flexible with capabilities since vtsls might have different structure - self.logger.log(f"VTS init response capabilities: {init_response['capabilities']}", logging.DEBUG) + log.debug(f"VTS init response capabilities: {init_response['capabilities']}") # Basic checks to ensure essential capabilities are present assert "textDocumentSync" in init_response["capabilities"] assert "completionProvider" in init_response["capabilities"] # Log the actual values for debugging - self.logger.log(f"textDocumentSync: {init_response['capabilities']['textDocumentSync']}", logging.DEBUG) - self.logger.log(f"completionProvider: {init_response['capabilities']['completionProvider']}", logging.DEBUG) + log.debug(f"textDocumentSync: {init_response['capabilities']['textDocumentSync']}") + log.debug(f"completionProvider: {init_response['capabilities']['completionProvider']}") self.server.notify.initialized({}) if self.server_ready.wait(timeout=1.0): - self.logger.log("VTS server is ready", logging.INFO) + log.info("VTS server is ready") else: - self.logger.log("Timeout waiting for VTS server to become ready, proceeding anyway", logging.INFO) + log.info("Timeout waiting for VTS server to become ready, proceeding anyway") # Fallback: assume server is ready after timeout self.server_ready.set() self.completions_available.set() diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py new file mode 100644 index 00000000..ee419345 --- /dev/null +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -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 diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py index 6e2dc959..6ec52501 100644 --- a/src/solidlsp/language_servers/yaml_language_server.py +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -13,11 +13,12 @@ from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class YamlLanguageServer(SolidLanguageServer): """ @@ -41,17 +42,14 @@ class YamlLanguageServer(SolidLanguageServer): return SolidLanguageServer._determine_log_level(line) - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a YamlLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - yaml_lsp_executable_path = self._setup_runtime_dependencies(logger, config, solidlsp_settings) + yaml_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, - logger, repository_root_path, ProcessLaunchInfo(cmd=yaml_lsp_executable_path, cwd=repository_root_path), "yaml", @@ -60,9 +58,7 @@ class YamlLanguageServer(SolidLanguageServer): self.server_ready = threading.Event() @classmethod - def _setup_runtime_dependencies( - cls, logger: LanguageServerLogger, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings - ) -> str: + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: """ Setup runtime dependencies for YAML Language Server and return the command to start the server. """ @@ -92,9 +88,9 @@ class YamlLanguageServer(SolidLanguageServer): yaml_executable_path += ".cmd" if not os.path.exists(yaml_executable_path): - logger.log(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...", logging.INFO) - deps.install(logger, yaml_ls_dir) - logger.log("YAML language server dependencies installed successfully", logging.INFO) + log.info(f"YAML Language Server executable not found at {yaml_executable_path}. Installing...") + deps.install(yaml_ls_dir) + log.info("YAML language server dependencies installed successfully") if not os.path.exists(yaml_executable_path): raise FileNotFoundError( @@ -162,34 +158,31 @@ class YamlLanguageServer(SolidLanguageServer): def do_nothing(params: Any) -> None: return - def window_log_message(msg: str) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") self.server.on_request("client/registerCapability", register_capability_handler) self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting YAML server process", logging.INFO) + log.info("Starting YAML server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) - self.logger.log(f"Received initialize response from YAML server: {init_response}", logging.DEBUG) + log.debug(f"Received initialize response from YAML server: {init_response}") # Verify document symbol support is available if "documentSymbolProvider" in init_response["capabilities"]: - self.logger.log("YAML server supports document symbols", logging.INFO) + log.info("YAML server supports document symbols") else: - self.logger.log("Warning: YAML server does not report document symbol support", logging.WARNING) + log.warning("Warning: YAML server does not report document symbol support") self.server.notify.initialized({}) # YAML language server is ready immediately after initialization - self.logger.log("YAML server initialization complete", logging.INFO) + log.info("YAML server initialization complete") self.server_ready.set() self.completions_available.set() diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py index 9ab8daac..f8019a4e 100644 --- a/src/solidlsp/language_servers/zls.py +++ b/src/solidlsp/language_servers/zls.py @@ -14,11 +14,12 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings +log = logging.getLogger(__name__) + class ZigLanguageServer(SolidLanguageServer): """ @@ -95,19 +96,10 @@ class ZigLanguageServer(SolidLanguageServer): return True - def __init__( - self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings - ): + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): self._setup_runtime_dependency() - super().__init__( - config, - logger, - repository_root_path, - ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), - "zig", - solidlsp_settings, - ) + super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), "zig", solidlsp_settings) self.server_ready = threading.Event() self.request_id = 0 @@ -196,8 +188,8 @@ class ZigLanguageServer(SolidLanguageServer): def register_capability_handler(params: dict) -> None: return - def window_log_message(msg: str) -> None: - self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") def do_nothing(params: dict) -> None: return @@ -207,14 +199,11 @@ class ZigLanguageServer(SolidLanguageServer): self.server.on_notification("$/progress", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) - self.logger.log("Starting ZLS server process", logging.INFO) + log.info("Starting ZLS server process") self.server.start() initialize_params = self._get_initialize_params(self.repository_root_path) - self.logger.log( - "Sending initialize request from LSP client to LSP server and awaiting response", - logging.INFO, - ) + log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) # Verify server capabilities @@ -247,6 +236,6 @@ class ZigLanguageServer(SolidLanguageServer): } } ) - self.logger.log("Opened build.zig to provide project context to ZLS", logging.INFO) + log.info("Opened build.zig to provide project context to ZLS") except Exception as e: - self.logger.log(f"Failed to open build.zig: {e}", logging.WARNING) + log.warning(f"Failed to open build.zig: {e}") diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index fcb0734f..be66e36c 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -25,7 +25,6 @@ from solidlsp import ls_types from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_handler import SolidLanguageServerHandler -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils from solidlsp.lsp_protocol_handler import lsp_types @@ -207,7 +206,6 @@ class SolidLanguageServer(ABC): def create( cls, config: LanguageServerConfig, - logger: LanguageServerLogger, repository_root_path: str, timeout: float | None = None, solidlsp_settings: SolidLSPSettings | None = None, @@ -229,18 +227,20 @@ 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). # If this assumption is ever violated, we need branching logic here. - ls = ls_class(config, logger, repository_root_path, solidlsp_settings) # type: ignore + ls = ls_class(config, repository_root_path, solidlsp_settings) # type: ignore ls.set_request_timeout(timeout) return ls def __init__( self, config: LanguageServerConfig, - logger: LanguageServerLogger, repository_root_path: str, process_launch_info: ProcessLaunchInfo, language_id: str, @@ -252,13 +252,11 @@ class SolidLanguageServer(ABC): Do not instantiate this class directly. Use `LanguageServer.create` method instead. - :param config: The Multilspy configuration. - :param logger: The logger to use. - :param repository_root_path: The root path of the repository. - :param process_launch_info: Each language server has a specific command used to start the server. - This parameter is the command to launch the language server process. - The command must pass appropriate flags to the binary, so that it runs in the stdio mode, - as opposed to HTTP, TCP modes supported by some language servers. + :param config: the global SolidLSP configuration. + :param repository_root_path: the root path of the repository. + :param process_launch_info: the command used to start the actual language server. + The command must pass appropriate flags to the binary, so that it runs in the stdio mode, + as opposed to HTTP, TCP modes supported by some language servers. :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming from this specific language server. This should be incremented by subclasses calling this constructor whenever the format of the raw document symbols changes (typically because the language server @@ -267,16 +265,11 @@ class SolidLanguageServer(ABC): self._solidlsp_settings = solidlsp_settings lang = self.get_language_enum_instance() self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) - logger.log( - f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}", - logging.DEBUG, - ) + log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}") self._encoding = config.encoding - self.logger = logger self.repository_root_path: str = repository_root_path - self.logger.log( - f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}", - logging.DEBUG, + log.debug( + f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}" ) self.language_id = language_id @@ -305,16 +298,14 @@ class SolidLanguageServer(ABC): if config.trace_lsp_communication: def logging_fn(source: str, target: str, msg: StringDict | str) -> None: - self.logger.log(f"LSP: {source} -> {target}: {msg!s}", self.logger.logger.level) + log.debug(f"LSP: {source} -> {target}: {msg!s}") else: logging_fn = None # type: ignore # cmd is obtained from the child classes, which provide the language specific command to start the language server # LanguageServerHandler provides the functionality to start the language server and communicate with it - self.logger.log( - f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}", logging.DEBUG - ) + log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}") self.server = SolidLanguageServerHandler( process_launch_info, language=self.language, @@ -330,7 +321,7 @@ class SolidLanguageServer(ABC): # Normalize separators (pathspec expects forward slashes) pattern = pattern.replace(os.path.sep, "/") processed_patterns.append(pattern) - self.logger.log(f"Processing {len(processed_patterns)} ignored paths from the config", logging.DEBUG) + log.debug(f"Processing {len(processed_patterns)} ignored paths from the config") # Create a pathspec matcher from the processed patterns self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) @@ -354,10 +345,11 @@ class SolidLanguageServer(ABC): self.server.set_request_timeout(timeout) def get_ignore_spec(self) -> pathspec.PathSpec: - """Returns the pathspec matcher for the paths that were configured to be ignored through - the multilspy config. + """ + Returns the pathspec matcher for the paths that were configured to be ignored through + the language server configuration. - This is is a subset of the full language-specific ignore spec that determines + This is a subset of the full language-specific ignore spec that determines which files are relevant for the language server. This matcher is useful for operations outside of the language server, @@ -407,20 +399,20 @@ class SolidLanguageServer(ABC): by explicitly closing all I/O pipes. """ if not self.server.is_running(): - self.logger.log("Server process not running, skipping shutdown.", logging.DEBUG) + log.debug("Server process not running, skipping shutdown.") return - self.logger.log(f"Initiating final robust shutdown with a {timeout}s timeout...", logging.INFO) + log.info(f"Initiating final robust shutdown with a {timeout}s timeout...") process = self.server.process if process is None: - self.logger.log("Server process is None, cannot shutdown.", logging.DEBUG) + log.debug("Server process is None, cannot shutdown.") return # --- Main Shutdown Logic --- # Stage 1: Graceful Termination Request # Send LSP shutdown and close stdin to signal no more input. try: - self.logger.log("Sending LSP shutdown request...", logging.DEBUG) + log.debug("Sending LSP shutdown request...") # Use a thread to timeout the LSP shutdown call since it can hang shutdown_thread = threading.Thread(target=self.server.shutdown) shutdown_thread.daemon = True @@ -428,37 +420,37 @@ class SolidLanguageServer(ABC): shutdown_thread.join(timeout=2.0) # 2 second timeout for LSP shutdown if shutdown_thread.is_alive(): - self.logger.log("LSP shutdown request timed out, proceeding to terminate...", logging.DEBUG) + log.debug("LSP shutdown request timed out, proceeding to terminate...") else: - self.logger.log("LSP shutdown request completed.", logging.DEBUG) + log.debug("LSP shutdown request completed.") if process.stdin and not process.stdin.closed: process.stdin.close() - self.logger.log("Stage 1 shutdown complete.", logging.DEBUG) + log.debug("Stage 1 shutdown complete.") except Exception as e: - self.logger.log(f"Exception during graceful shutdown: {e}", logging.DEBUG) + log.debug(f"Exception during graceful shutdown: {e}") # Ignore errors here, we are proceeding to terminate anyway. # Stage 2: Terminate and Wait for Process to Exit - self.logger.log(f"Terminating process {process.pid}, current status: {process.poll()}", logging.DEBUG) + log.debug(f"Terminating process {process.pid}, current status: {process.poll()}") process.terminate() # Stage 3: Wait for process termination with timeout try: - self.logger.log(f"Waiting for process {process.pid} to terminate...", logging.DEBUG) + log.debug(f"Waiting for process {process.pid} to terminate...") exit_code = process.wait(timeout=timeout) - self.logger.log(f"Language server process terminated successfully with exit code {exit_code}.", logging.INFO) + log.info(f"Language server process terminated successfully with exit code {exit_code}.") except subprocess.TimeoutExpired: # If termination failed, forcefully kill the process - self.logger.log(f"Process {process.pid} termination timed out, killing process forcefully...", logging.WARNING) + log.warning(f"Process {process.pid} termination timed out, killing process forcefully...") process.kill() try: exit_code = process.wait(timeout=2.0) - self.logger.log(f"Language server process killed successfully with exit code {exit_code}.", logging.INFO) + log.info(f"Language server process killed successfully with exit code {exit_code}.") except subprocess.TimeoutExpired: - self.logger.log(f"Process {process.pid} could not be killed within timeout.", logging.ERROR) + log.error(f"Process {process.pid} could not be killed within timeout.") except Exception as e: - self.logger.log(f"Error during process shutdown: {e}", logging.ERROR) + log.error(f"Error during process shutdown: {e}") @contextmanager def start_server(self) -> Iterator["SolidLanguageServer"]: @@ -474,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]: """ @@ -482,10 +482,7 @@ class SolidLanguageServer(ABC): :param relative_file_path: The relative path of the file to open. """ if not self.server_started: - self.logger.log( - "open_file called before Language Server started", - logging.ERROR, - ) + log.error("open_file called before Language Server started") raise SolidLSPException("Language Server not started") absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) @@ -502,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, } @@ -552,10 +550,7 @@ class SolidLanguageServer(ABC): :param text_to_be_inserted: The text to insert. """ if not self.server_started: - self.logger.log( - "insert_text_at_position called before Language Server started", - logging.ERROR, - ) + log.error("insert_text_at_position called before Language Server started") raise SolidLSPException("Language Server not started") absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) @@ -598,10 +593,7 @@ class SolidLanguageServer(ABC): Delete text between the given start and end positions in the given file and return the deleted text. """ if not self.server_started: - self.logger.log( - "insert_text_at_position called before Language Server started", - logging.ERROR, - ) + log.error("insert_text_at_position called before Language Server started") raise SolidLSPException("Language Server not started") absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) @@ -639,13 +631,10 @@ class SolidLanguageServer(ABC): :param line: The line number of the symbol :param column: The column number of the symbol - :return List[multilspy_types.Location]: A list of locations where the symbol is defined + :return: the list of locations where the symbol is defined """ if not self.server_started: - self.logger.log( - "request_definition called before Language Server started", - logging.ERROR, - ) + log.error("request_definition called before language server started") raise SolidLSPException("Language Server not started") if not self._has_waited_for_cross_file_references: @@ -703,10 +692,7 @@ class SolidLanguageServer(ABC): elif response is None: # Some language servers return None when they cannot find a definition # This is expected for certain symbol types like generics or types with incomplete information - self.logger.log( - f"Language server returned None for definition request at {relative_file_path}:{line}:{column}", - logging.WARNING, - ) + log.warning(f"Language server returned None for definition request at {relative_file_path}:{line}:{column}") else: assert False, f"Unexpected response from Language Server: {response}" @@ -735,10 +721,7 @@ class SolidLanguageServer(ABC): :return: A list of locations where the symbol is referenced (excluding ignored directories) """ if not self.server_started: - self.logger.log( - "request_references called before Language Server started", - logging.ERROR, - ) + log.error("request_references called before Language Server started") raise SolidLSPException("Language Server not started") if not self._has_waited_for_cross_file_references: @@ -770,16 +753,15 @@ class SolidLanguageServer(ABC): abs_path = PathUtils.uri_to_path(item[LSPConstants.URI]) # type: ignore if not Path(abs_path).is_relative_to(self.repository_root_path): - self.logger.log( + log.warning( "Found a reference in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " - f"Path: {abs_path}. This is a bug but we currently simply skip these references.", - logging.WARNING, + f"Path: {abs_path}. This is a bug but we currently simply skip these references." ) continue rel_path = Path(abs_path).relative_to(self.repository_root_path) if self.is_ignored_path(str(rel_path)): - self.logger.log(f"Ignoring reference in {rel_path} since it should be ignored", logging.DEBUG) + log.debug("Ignoring reference in %s since it should be ignored", rel_path) continue new_item: dict = {} @@ -800,10 +782,7 @@ class SolidLanguageServer(ABC): :return: A list of diagnostics for the file """ if not self.server_started: - self.logger.log( - "request_text_document_diagnostics called before Language Server started", - logging.ERROR, - ) + log.error("request_text_document_diagnostics called before Language Server started") raise SolidLSPException("Language Server not started") with self.open_file(relative_file_path): @@ -875,7 +854,7 @@ class SolidLanguageServer(ABC): :param line: The line number of the symbol :param column: The column number of the symbol - :return List[multilspy_types.CompletionItem]: A list of completions + :return: A list of completions """ with self.open_file(relative_file_path): open_file_buffer = self.open_file_buffers[pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()] @@ -983,7 +962,7 @@ class SolidLanguageServer(ABC): return response # no cached result, query language server - self.logger.log(f"Requesting document symbols for {relative_file_path} from the Language Server", logging.DEBUG) + log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server") response = self.server.send.document_symbol( {"textDocument": {"uri": pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)).as_uri()}} ) @@ -1420,10 +1399,7 @@ class SolidLanguageServer(ABC): :return: List of objects containing the symbol and the location of the reference. """ if not self.server_started: - self.logger.log( - "request_referencing_symbols called before Language Server started", - logging.ERROR, - ) + log.error("request_referencing_symbols called before Language Server started") raise SolidLSPException("Language Server not started") # First, get all references to the symbol @@ -1470,10 +1446,7 @@ class SolidLanguageServer(ABC): # We failed retrieving the symbol, falling back to creating a file symbol if containing_symbol is None and include_file_symbols: - self.logger.log( - f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead", - logging.WARNING, - ) + log.warning(f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead") fileRange = self._get_range_from_file_content(file_data.contents) location = ls_types.Location( uri=str(pathlib.Path(os.path.join(self.repository_root_path, ref_path)).as_uri()), @@ -1513,7 +1486,7 @@ class SolidLanguageServer(ABC): if include_self: result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) continue - self.logger.log(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}", logging.DEBUG) + log.debug(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}") continue # checking whether reference is an import @@ -1526,10 +1499,9 @@ class SolidLanguageServer(ABC): and containing_symbol["name"] == incoming_symbol["name"] and containing_symbol["kind"] == incoming_symbol["kind"] ): - self.logger.log( + log.debug( f"Found import of referenced symbol {incoming_symbol['name']}" - f"in {containing_symbol['location']['relativePath']}, skipping", - logging.DEBUG, + f"in {containing_symbol['location']['relativePath']}, skipping" ) continue @@ -1576,10 +1548,7 @@ class SolidLanguageServer(ABC): absolute_file_path = str(PurePath(self.repository_root_path, relative_file_path)) content = FileUtils.read_file(absolute_file_path, self._encoding) if content.split("\n")[line].strip() == "": - self.logger.log( - f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}", - logging.ERROR, - ) + log.error(f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}") return None document_symbols = self.request_document_symbols(relative_file_path) @@ -1673,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, @@ -1693,10 +1679,7 @@ class SolidLanguageServer(ABC): :return: The symbol information for the definition, or None if not found. """ if not self.server_started: - self.logger.log( - "request_defining_symbol called before Language Server started", - logging.ERROR, - ) + log.error("request_defining_symbol called before language server started") raise SolidLSPException("Language Server not started") # Get the definition location(s) @@ -1704,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"] @@ -1773,9 +1756,7 @@ class SolidLanguageServer(ABC): saved_cache = load_cache(str(cache_file), self._raw_document_symbols_cache_version()) if saved_cache is not None: self._raw_document_symbols_cache = saved_cache - self.logger.log( - f"Loaded {len(self._raw_document_symbols_cache)} entries from raw document symbols cache.", logging.INFO - ) + log.info(f"Loaded {len(self._raw_document_symbols_cache)} entries from raw document symbols cache.") except Exception as e: # cache can become corrupt, so just skip loading it log.warning( @@ -1810,7 +1791,7 @@ class SolidLanguageServer(ABC): saved_cache = load_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION) if saved_cache is not None: self._document_symbols_cache = saved_cache - self.logger.log(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.", logging.INFO) + log.info(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.") except Exception as e: # cache can become corrupt, so just skip loading it log.warning( @@ -1902,10 +1883,7 @@ class SolidLanguageServer(ABC): :return: self for method chaining """ - self.logger.log( - f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}", - logging.INFO, - ) + log.info(f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}") self._start_server_process() return self @@ -1919,11 +1897,20 @@ class SolidLanguageServer(ABC): try: self._shutdown(timeout=shutdown_timeout) except Exception as e: - self.logger.log(f"Exception while shutting down language server: {e}", logging.WARNING) + log.warning(f"Exception while shutting down language server: {e}") @property 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() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 7a09588f..57edbb02 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -28,7 +28,7 @@ class FilenameMatcher: class Language(str, Enum): """ - Possible languages with Multilspy. + Enumeration of language servers supported by SolidLSP. """ CSHARP = "csharp" @@ -55,11 +55,15 @@ class Language(str, Enum): NIX = "nix" ERLANG = "erlang" AL = "al" + FSHARP = "fsharp" REGO = "rego" SCALA = "scala" JULIA = "julia" FORTRAN = "fortran" HASKELL = "haskell" + GROOVY = "groovy" + 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""" @@ -82,6 +86,10 @@ class Language(str, Enum): """YAML language server (experimental). Must be explicitly specified as the main language, not auto-detected. """ + TOML = "toml" + """TOML language server using Taplo. + Supports TOML validation, formatting, and schema support. + """ @classmethod def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]: @@ -92,12 +100,42 @@ class Language(str, Enum): def is_experimental(self) -> bool: """ Check if the language server is experimental or deprecated. + + Note for serena users/developers: + Experimental languages are not autodetected and must be explicitly specified + in the project.yml configuration. """ - return self in {self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.MARKDOWN, self.YAML} + return self in { + self.TYPESCRIPT_VTS, + self.PYTHON_JEDI, + self.CSHARP_OMNISHARP, + self.RUBY_SOLARGRAPH, + self.MARKDOWN, + self.YAML, + self.TOML, + self.GROOVY, + } 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: @@ -148,6 +186,8 @@ class Language(str, Enum): return FilenameMatcher("*.sh", "*.bash") case self.YAML: return FilenameMatcher("*.yaml", "*.yml") + case self.TOML: + return FilenameMatcher("*.toml") case self.ZIG: return FilenameMatcher("*.zig", "*.zon") case self.LUA: @@ -158,6 +198,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: @@ -172,6 +214,17 @@ 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 self.GROOVY: + return FilenameMatcher("*.groovy", "*.gvy") case _: raise ValueError(f"Unhandled language: {self}") @@ -213,6 +266,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 @@ -269,6 +326,10 @@ class Language(str, Enum): from solidlsp.language_servers.yaml_language_server import YamlLanguageServer return YamlLanguageServer + case self.TOML: + from solidlsp.language_servers.taplo_server import TaploServer + + return TaploServer case self.ZIG: from solidlsp.language_servers.zls import ZigLanguageServer @@ -317,6 +378,18 @@ 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 self.GROOVY: + from solidlsp.language_servers.groovy_language_server import GroovyLanguageServer + + return GroovyLanguageServer case _: raise ValueError(f"Unhandled language: {self}") @@ -351,9 +424,6 @@ class LanguageServerConfig: @classmethod def from_dict(cls, env: dict) -> Self: - """ - Create a MultilspyConfig instance from a dictionary - """ import inspect return cls(**{k: v for k, v in env.items() if k in inspect.signature(cls).parameters}) diff --git a/src/solidlsp/ls_logger.py b/src/solidlsp/ls_logger.py deleted file mode 100644 index 7aa0e0a6..00000000 --- a/src/solidlsp/ls_logger.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Multilspy logger module. -""" - -import inspect -import logging -from datetime import datetime - -from pydantic import BaseModel - - -class LogLine(BaseModel): - """ - Represents a line in the Multilspy log - """ - - time: str - level: str - caller_file: str - caller_name: str - caller_line: int - message: str - - -class LanguageServerLogger: - """ - Logger class - """ - - def __init__(self, json_format: bool = False, log_level: int = logging.INFO) -> None: - self.logger = logging.getLogger("solidlsp") - self.logger.setLevel(log_level) - self.json_format = json_format - - def log(self, debug_message: str, level: int, sanitized_error_message: str = "", stacklevel: int = 2) -> None: - """ - Log the debug and sanitized messages using the logger - """ - debug_message = debug_message.replace("'", '"').replace("\n", " ") - sanitized_error_message = sanitized_error_message.replace("'", '"').replace("\n", " ") - - # Collect details about the callee - curframe = inspect.currentframe() - calframe = inspect.getouterframes(curframe, 2) - caller_file = calframe[1][1].split("/")[-1] - caller_line = calframe[1][2] - caller_name = calframe[1][3] - - if self.json_format: - # Construct the debug log line - debug_log_line = LogLine( - time=str(datetime.now().strftime("%Y-%m-%d %H:%M:%S")), - level=logging.getLevelName(level), - caller_file=caller_file, - caller_name=caller_name, - caller_line=caller_line, - message=debug_message, - ) - - self.logger.log( - level=level, - msg=debug_log_line.json(), - stacklevel=stacklevel, - ) - else: - self.logger.log(level, debug_message, stacklevel=stacklevel) diff --git a/src/solidlsp/ls_types.py b/src/solidlsp/ls_types.py index a1baab88..e8899602 100644 --- a/src/solidlsp/ls_types.py +++ b/src/solidlsp/ls_types.py @@ -1,5 +1,5 @@ """ -Defines wrapper objects around the types returned by LSP to ensure decoupling between LSP versions and multilspy +Defines wrapper objects around the types returned by LSP to ensure decoupling between LSP versions and SolidLSP """ from __future__ import annotations @@ -371,28 +371,3 @@ class Diagnostic(TypedDict): """ The code of the diagnostic. """ source: NotRequired[str] """ The source of the diagnostic, e.g. the name of the tool that produced it. """ - - -def extract_text_edits(workspace_edit: WorkspaceEdit) -> dict[str, list[TextEdit]]: - """ - Extracts the text changes from a WorkspaceEdit object. - - Args: - workspace_edit (WorkspaceEdit): The WorkspaceEdit object to extract text changes from. - - Returns: - dict[str, list[TextEdit]]: A dictionary mapping document URIs to lists of TextEdit objects. - - """ - if "changes" in workspace_edit: - return workspace_edit["changes"] - elif "documentChanges" in workspace_edit: - changes = {} - for change in workspace_edit["documentChanges"]: - if "textDocument" in change and "edits" in change: - uri = change["textDocument"]["uri"] - edits = change["edits"] - changes[uri] = edits - return changes - else: - raise Exception(f"Invalid WorkspaceEdit (expected 'changes' or 'documentChanges' key):\n{workspace_edit}") diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 08e6a279..b8aa6845 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -17,7 +17,6 @@ import charset_normalizer import requests from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.ls_types import UnifiedSymbolInformation log = logging.getLogger(__name__) @@ -201,7 +200,7 @@ class FileUtils: raise exc @staticmethod - def download_file(logger: LanguageServerLogger, url: str, target_path: str) -> None: + def download_file(url: str, target_path: str) -> None: """ Downloads the file from the given URL to the given {target_path} """ @@ -209,25 +208,25 @@ class FileUtils: try: response = requests.get(url, stream=True, timeout=60) if response.status_code != 200: - logger.log(f"Error downloading file '{url}': {response.status_code} {response.text}", logging.ERROR) + log.error(f"Error downloading file '{url}': {response.status_code} {response.text}") raise SolidLSPException("Error downloading file.") with open(target_path, "wb") as f: shutil.copyfileobj(response.raw, f) except Exception as exc: - logger.log(f"Error downloading file '{url}': {exc}", logging.ERROR) + log.error(f"Error downloading file '{url}': {exc}") raise SolidLSPException("Error downloading file.") from None @staticmethod - def download_and_extract_archive(logger: LanguageServerLogger, url: str, target_path: str, archive_type: str) -> None: + def download_and_extract_archive(url: str, target_path: str, archive_type: str) -> None: """ Downloads the archive from the given URL having format {archive_type} and extracts it to the given {target_path} """ try: tmp_files = [] - tmp_file_name = str(PurePath(os.path.expanduser("~"), "multilspy_tmp", uuid.uuid4().hex)) + tmp_file_name = str(PurePath(os.path.expanduser("~"), "solidlsp_tmp", uuid.uuid4().hex)) tmp_files.append(tmp_file_name) os.makedirs(os.path.dirname(tmp_file_name), exist_ok=True) - FileUtils.download_file(logger, url, tmp_file_name) + FileUtils.download_file(url, tmp_file_name) if archive_type in ["tar", "gztar", "bztar", "xztar"]: os.makedirs(target_path, exist_ok=True) shutil.unpack_archive(tmp_file_name, target_path, archive_type) @@ -258,10 +257,10 @@ class FileUtils: # For single binary files, just move to target without extraction shutil.move(tmp_file_name, target_path) else: - logger.log(f"Unknown archive type '{archive_type}' for extraction", logging.ERROR) + log.error(f"Unknown archive type '{archive_type}' for extraction") raise SolidLSPException(f"Unknown archive type '{archive_type}'") except Exception as exc: - logger.log(f"Error extracting archive '{tmp_file_name}' obtained from '{url}': {exc}", logging.ERROR) + log.error(f"Error extracting archive '{tmp_file_name}' obtained from '{url}': {exc}") raise SolidLSPException("Error extracting archive.") from exc finally: for tmp_file_name in tmp_files: @@ -270,10 +269,6 @@ class FileUtils: class PlatformId(str, Enum): - """ - multilspy supported platforms - """ - WIN_x86 = "win-x86" WIN_x64 = "win-x64" WIN_arm64 = "win-arm64" @@ -291,10 +286,6 @@ class PlatformId(str, Enum): class DotnetVersion(str, Enum): - """ - multilspy supported dotnet versions - """ - V4 = "4" V6 = "6" V7 = "7" diff --git a/src/solidlsp/lsp_protocol_handler/server.py b/src/solidlsp/lsp_protocol_handler/server.py index 219135b7..3a10e10c 100644 --- a/src/solidlsp/lsp_protocol_handler/server.py +++ b/src/solidlsp/lsp_protocol_handler/server.py @@ -46,17 +46,25 @@ log = logging.getLogger(__name__) @dataclasses.dataclass class ProcessLaunchInfo: """ - This class is used to store the information required to launch a process. + This class is used to store the information required to launch a (language server) process. """ - # The command to launch the process cmd: str | list[str] + """ + the command used to launch the process. + Specification as a list is preferred (as it is more robust and avoids incorrect quoting of arguments); + the string variant is supported for backward compatibility only + """ - # The environment variables to set for the process env: dict[str, str] = dataclasses.field(default_factory=dict) + """ + the environment variables to set for the process + """ - # The working directory for the process cwd: str = os.getcwd() + """ + the working directory for the process + """ class LSPError(Exception): @@ -84,11 +92,15 @@ def make_error_response(request_id: Any, err: LSPError) -> StringDict: def make_notification(method: str, params: PayloadLike) -> StringDict: - return {"jsonrpc": "2.0", "method": method, "params": params} + # JSON-RPC 2.0: params must be object or array if present, cannot be null + # Some language servers require params to be present, so we send empty object instead of omitting + return {"jsonrpc": "2.0", "method": method, "params": params if params is not None else {}} def make_request(method: str, request_id: Any, params: PayloadLike) -> StringDict: - return {"jsonrpc": "2.0", "method": method, "id": request_id, "params": params} + # JSON-RPC 2.0: params must be object or array if present, cannot be null + # Some language servers require params to be present, so we send empty object instead of omitting + return {"jsonrpc": "2.0", "method": method, "id": request_id, "params": params if params is not None else {}} class StopLoopException(Exception): diff --git a/test/conftest.py b/test/conftest.py index 1ef2501c..84aa80f3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,18 +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.ls_logger import LanguageServerLogger 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") @@ -30,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: @@ -44,21 +47,46 @@ def create_ls( for spec in gitignore_parser.get_ignore_specs(): ignored_paths.extend(spec.patterns) config = LanguageServerConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication) - logger = LanguageServerLogger(log_level=log_level) return SolidLanguageServer.create( config, - logger, 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) @@ -84,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. @@ -111,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. @@ -146,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 diff --git a/test/resources/repos/fsharp/test_repo/.gitignore b/test/resources/repos/fsharp/test_repo/.gitignore new file mode 100644 index 00000000..975cf30f --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.user +.vscode/ +.ionide/ \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Calculator.fs b/test/resources/repos/fsharp/test_repo/Calculator.fs new file mode 100644 index 00000000..e332945b --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Calculator.fs @@ -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 \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Models/Person.fs b/test/resources/repos/fsharp/test_repo/Models/Person.fs new file mode 100644 index 00000000..a2966923 --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Models/Person.fs @@ -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 +} \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Program.fs b/test/resources/repos/fsharp/test_repo/Program.fs new file mode 100644 index 00000000..e5328ceb --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Program.fs @@ -0,0 +1,37 @@ +module Program + +open Calculator +open Models + +[] +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 \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/README.md b/test/resources/repos/fsharp/test_repo/README.md new file mode 100644 index 00000000..fdef17cb --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/README.md @@ -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 +``` \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/TestProject.fsproj b/test/resources/repos/fsharp/test_repo/TestProject.fsproj new file mode 100644 index 00000000..39b621ff --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/TestProject.fsproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + + + + + + + + + \ No newline at end of file diff --git a/test/resources/repos/groovy/test_repo/.gitignore b/test/resources/repos/groovy/test_repo/.gitignore new file mode 100644 index 00000000..3bb19ffd --- /dev/null +++ b/test/resources/repos/groovy/test_repo/.gitignore @@ -0,0 +1 @@ +.gradle/ diff --git a/test/resources/repos/groovy/test_repo/build.gradle b/test/resources/repos/groovy/test_repo/build.gradle new file mode 100644 index 00000000..61a1de3a --- /dev/null +++ b/test/resources/repos/groovy/test_repo/build.gradle @@ -0,0 +1,7 @@ +plugins { + id 'groovy' +} + +repositories { + mavenCentral() +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Main.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Main.groovy new file mode 100644 index 00000000..0e42fe71 --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Main.groovy @@ -0,0 +1,9 @@ +package com.example + +class Main { + static void main(String[] args) { + Utils.printHello() + Model model = new Model("Cascade") + println(model.name) + } +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Model.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Model.groovy new file mode 100644 index 00000000..f6451ca4 --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Model.groovy @@ -0,0 +1,9 @@ +package com.example + +class Model { + String name + + Model(String name) { + this.name = name + } +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/ModelUser.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/ModelUser.groovy new file mode 100644 index 00000000..bde77812 --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/ModelUser.groovy @@ -0,0 +1,8 @@ +package com.example + +class ModelUser { + static void main(String[] args) { + Model model = new Model("Cascade") + println(model.name) + } +} diff --git a/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Utils.groovy b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Utils.groovy new file mode 100644 index 00000000..b2cf63a1 --- /dev/null +++ b/test/resources/repos/groovy/test_repo/src/main/groovy/com/example/Utils.groovy @@ -0,0 +1,7 @@ +package com.example + +class Utils { + static void printHello() { + println("Hello from Utils!") + } +} diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java index a2c6459b..5a6a044e 100644 --- a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java @@ -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) + "..."; + } + } } diff --git a/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json b/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json new file mode 100644 index 00000000..20147867 --- /dev/null +++ b/test/resources/repos/powershell/test_repo/PowerShellEditorServices.json @@ -0,0 +1 @@ +{"status":"started","languageServiceTransport":"Stdio","powerShellVersion":"7.5.3"} \ No newline at end of file diff --git a/test/resources/repos/powershell/test_repo/main.ps1 b/test/resources/repos/powershell/test_repo/main.ps1 new file mode 100644 index 00000000..6829fda4 --- /dev/null +++ b/test/resources/repos/powershell/test_repo/main.ps1 @@ -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 diff --git a/test/resources/repos/powershell/test_repo/utils.ps1 b/test/resources/repos/powershell/test_repo/utils.ps1 new file mode 100644 index 00000000..81db68e7 --- /dev/null +++ b/test/resources/repos/powershell/test_repo/utils.ps1 @@ -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) +} diff --git a/test/resources/repos/scala/src/main/scala/com/example/Main.scala b/test/resources/repos/scala/src/main/scala/com/example/Main.scala index f7197ec3..2407efec 100644 --- a/test/resources/repos/scala/src/main/scala/com/example/Main.scala +++ b/test/resources/repos/scala/src/main/scala/com/example/Main.scala @@ -17,4 +17,13 @@ object Main { def add(a: Int, b: Int): Int = { a + b } + + // https://github.com/oraios/serena/issues/688 + def someMethod(config: Config): Unit = { + val str = config.field1 + + println(str) + } + + case class Config(field1:String) } diff --git a/test/resources/repos/toml/test_repo/Cargo.toml b/test/resources/repos/toml/test_repo/Cargo.toml new file mode 100644 index 00000000..a295cc0d --- /dev/null +++ b/test/resources/repos/toml/test_repo/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "test_project" +version = "0.1.0" +edition = "2021" +description = "A test TOML file for Serena TOML language support" +authors = ["Test Author "] +license = "MIT" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.0", features = ["full"] } + +[dev-dependencies] +proptest = "1.0" + +[features] +default = ["feature1"] +feature1 = [] +feature2 = ["feature1"] + +[profile.release] +lto = true +opt-level = 3 + +[[bin]] +name = "main" +path = "src/main.rs" + +[workspace] +members = ["crates/*"] diff --git a/test/resources/repos/toml/test_repo/config.toml b/test/resources/repos/toml/test_repo/config.toml new file mode 100644 index 00000000..912d2dc7 --- /dev/null +++ b/test/resources/repos/toml/test_repo/config.toml @@ -0,0 +1,66 @@ +# Configuration file with various TOML features for testing + +[server] +host = "localhost" +port = 8080 +debug = false +timeout = 30.5 + +# Inline table example +endpoint = { url = "https://api.example.com", version = "v1" } + +[server.ssl] +enabled = true +cert_path = "/etc/ssl/cert.pem" +key_path = "/etc/ssl/key.pem" + +[database] +connection_string = """ + postgresql://user:password@localhost:5432/mydb? + sslmode=require& + pool_size=10 +""" + +[database.pool] +min_connections = 5 +max_connections = 20 +idle_timeout = 300 + +[logging] +level = "info" +format = "json" +outputs = ["stdout", "file"] + +[logging.file] +path = "/var/log/app.log" +max_size = 10485760 # 10MB +max_backups = 5 +compress = true + +# Array of inline tables +[[endpoints]] +name = "users" +path = "/api/users" +methods = ["GET", "POST", "PUT", "DELETE"] +auth_required = true + +[[endpoints]] +name = "health" +path = "/health" +methods = ["GET"] +auth_required = false + +# Datetime values +[metadata] +created = 1979-05-27T07:32:00Z +updated = 2024-01-15T10:30:00-05:00 + +# Special characters in strings +[messages] +welcome = "Hello, World!" +multiline = ''' +This is a +multiline literal +string. +''' +with_escapes = "Line1\nLine2\tTabbed" diff --git a/test/resources/repos/toml/test_repo/pyproject.toml b/test/resources/repos/toml/test_repo/pyproject.toml new file mode 100644 index 00000000..05028558 --- /dev/null +++ b/test/resources/repos/toml/test_repo/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "test-project" +version = "0.1.0" +description = "A test Python project for TOML support" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Test Author", email = "test@example.com"} +] +dependencies = [ + "pydantic>=2.0", + "httpx>=0.24", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "ruff>=0.1", + "mypy>=1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 88 +target-version = "py311" +select = ["E", "F", "I", "N", "W", "B", "S"] + +[tool.ruff.isort] +known-first-party = ["test_project"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unreachable = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-v --cov=src" diff --git a/test/resources/repos/typescript/test_repo/ws_manager.js b/test/resources/repos/typescript/test_repo/ws_manager.js new file mode 100644 index 00000000..60173214 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/ws_manager.js @@ -0,0 +1,74 @@ +/** + * Dummy WebSocket manager class for testing ambiguous regex matching. + */ +class WebSocketManager { + constructor() { + console.log("WebSocketManager initializing\nStatus OK"); + this.ws = null; + this.statusElement = document.getElementById("status"); + } + + /** + * Connects to the WebSocket server. + */ + connectToServer() { + if (this.ws?.readyState === WebSocket.OPEN) { + this.updateConnectionStatus("Already connected", true); + return; + } + + try { + this.ws = new WebSocket("ws://localhost:4402"); + this.updateConnectionStatus("Connecting...", false); + + this.ws.onopen = () => { + console.log("Connected to server"); + this.updateConnectionStatus("Connected", true); + }; + + this.ws.onmessage = (event) => { + console.log("Received message:", event.data); + try { + const data = JSON.parse(event.data); + this.handleMessage(data); + } catch (error) { + console.error("Failed to parse message:", error); + this.updateConnectionStatus("Parse error", false); + } + }; + + this.ws.onclose = (event) => { + console.log("Connection closed"); + const message = event.reason || undefined; + this.updateConnectionStatus("Disconnected", false, message); + this.ws = null; + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.updateConnectionStatus("Connection error", false); + }; + } catch (error) { + console.error("Failed to connect to server:", error); + this.updateConnectionStatus("Connection failed", false); + } + } + + /** + * Updates the connection status display. + */ + updateConnectionStatus(status, isConnected, message) { + if (this.statusElement) { + const text = message ? `${status}: ${message}` : status; + this.statusElement.textContent = text; + this.statusElement.style.color = isConnected ? "green" : "red"; + } + } + + /** + * Handles incoming messages. + */ + handleMessage(data) { + console.log("Handling:", data); + } +} \ No newline at end of file diff --git a/test/resources/repos/vue/test_repo/.gitignore b/test/resources/repos/vue/test_repo/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/test/resources/repos/vue/test_repo/.gitignore @@ -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? diff --git a/test/resources/repos/vue/test_repo/index.html b/test/resources/repos/vue/test_repo/index.html new file mode 100644 index 00000000..75e52237 --- /dev/null +++ b/test/resources/repos/vue/test_repo/index.html @@ -0,0 +1,13 @@ + + + + + + + Vue Calculator + + +
+ + + diff --git a/test/resources/repos/vue/test_repo/package.json b/test/resources/repos/vue/test_repo/package.json new file mode 100644 index 00000000..846a5b1b --- /dev/null +++ b/test/resources/repos/vue/test_repo/package.json @@ -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" + } +} diff --git a/test/resources/repos/vue/test_repo/src/App.vue b/test/resources/repos/vue/test_repo/src/App.vue new file mode 100644 index 00000000..a5ece8a0 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/App.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue b/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue new file mode 100644 index 00000000..622709b0 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue b/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue new file mode 100644 index 00000000..2d7bc86d --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts b/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts new file mode 100644 index 00000000..9c64106f --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts @@ -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(initialPrecision) + const useGrouping = ref(true) + const locale = ref('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 +export type UseTimeFormatterReturn = ReturnType diff --git a/test/resources/repos/vue/test_repo/src/composables/useTheme.ts b/test/resources/repos/vue/test_repo/src/composables/useTheme.ts new file mode 100644 index 00000000..706f1fe7 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/composables/useTheme.ts @@ -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> = 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(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 + } +} diff --git a/test/resources/repos/vue/test_repo/src/main.ts b/test/resources/repos/vue/test_repo/src/main.ts new file mode 100644 index 00000000..764d9587 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/main.ts @@ -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') diff --git a/test/resources/repos/vue/test_repo/src/stores/calculator.ts b/test/resources/repos/vue/test_repo/src/stores/calculator.ts new file mode 100644 index 00000000..4c8a4415 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/stores/calculator.ts @@ -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 = [] + } + } +}) diff --git a/test/resources/repos/vue/test_repo/src/types/index.ts b/test/resources/repos/vue/test_repo/src/types/index.ts new file mode 100644 index 00000000..2766ee2a --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/types/index.ts @@ -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 +} diff --git a/test/resources/repos/vue/test_repo/tsconfig.json b/test/resources/repos/vue/test_repo/tsconfig.json new file mode 100644 index 00000000..8c9fa9fc --- /dev/null +++ b/test/resources/repos/vue/test_repo/tsconfig.json @@ -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" }] +} diff --git a/test/resources/repos/vue/test_repo/tsconfig.node.json b/test/resources/repos/vue/test_repo/tsconfig.node.json new file mode 100644 index 00000000..97ede7ee --- /dev/null +++ b/test/resources/repos/vue/test_repo/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/test/resources/repos/vue/test_repo/vite.config.ts b/test/resources/repos/vue/test_repo/vite.config.ts new file mode 100644 index 00000000..4343664e --- /dev/null +++ b/test/resources/repos/vue/test_repo/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)) + } + } +}) diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index 92603c2d..347862ca 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -78,6 +78,229 @@ ''' # --- +# name: test_delete_symbol_vue[test_case0] + ''' + + + + + + + ''' +# --- # name: test_insert_in_rel_to_symbol[test_case0-after] ''' """ @@ -590,6 +813,466 @@ ''' # --- +# name: test_insert_in_rel_to_symbol_vue[test_case0-after] + ''' + + + + + + + ''' +# --- +# name: test_insert_in_rel_to_symbol_vue[test_case0-before] + ''' + + + + + + + ''' +# --- # name: test_insert_python_class_after ''' """ @@ -1207,3 +1890,641 @@ ''' # --- +# name: test_replace_body_vue[test_case0] + ''' + + + + + + + ''' +# --- +# name: test_replace_body_vue_ts_file[test_case0] + ''' + 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 + */ + 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; + }, + + /** + * Clear all history + */ + clearHistory() { + this.history = [] + } + } + }) + + ''' +# --- +# name: test_replace_body_vue_with_disambiguation[test_case0] + ''' + + + + + + + ''' +# --- diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index f22973ee..048b3dbc 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -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 diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index ccdf79a3..893299e6 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -4,11 +4,12 @@ import os import shutil import tempfile import time +from pathlib import Path import pytest from click.testing import CliRunner -from serena.cli import ProjectCommands +from serena.cli import ProjectCommands, TopLevelCommands, find_project_root from serena.config.serena_config import ProjectConfig pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") @@ -262,6 +263,85 @@ class TestProjectCreateHelper: ProjectCommands._create_project(temp_project_dir, None, ("python",)) +class TestFindProjectRoot: + """Tests for find_project_root helper with virtual chroot boundary.""" + + def test_finds_serena_from_subdirectory(self, temp_project_dir): + """Test that .serena/project.yml is found when searching from a subdirectory.""" + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + subdir = os.path.join(temp_project_dir, "src", "nested") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_serena_preferred_over_git(self, temp_project_dir): + """Test that .serena/project.yml takes priority over .git at the same level.""" + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + os.makedirs(os.path.join(temp_project_dir, ".git")) + + original_cwd = os.getcwd() + try: + os.chdir(temp_project_dir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.isdir(os.path.join(result, ".serena")) + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_git_used_as_fallback(self, temp_project_dir): + """Test that .git is found when no .serena exists.""" + os.makedirs(os.path.join(temp_project_dir, ".git")) + subdir = os.path.join(temp_project_dir, "src") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_falls_back_to_cwd_when_no_markers(self, temp_project_dir): + """Test falls back to CWD when no markers exist within boundary.""" + subdir = os.path.join(temp_project_dir, "src") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert os.path.samefile(result, subdir) + finally: + os.chdir(original_cwd) + + +class TestProjectFromCwdMutualExclusivity: + """Tests for --project-from-cwd mutual exclusivity.""" + + def test_project_from_cwd_with_project_flag_fails(self, cli_runner): + """Test that --project-from-cwd with --project raises error.""" + result = cli_runner.invoke( + TopLevelCommands.start_mcp_server, + ["--project-from-cwd", "--project", "/some/path"], + ) + assert result.exit_code != 0 + assert "cannot be used with" in result.output + + if __name__ == "__main__": # For manual testing, you can run this file directly: # uv run pytest test/serena/test_cli_project_commands.py -v diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index e2ac4c62..761affc2 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -1,17 +1,21 @@ import json import logging import os +import re import time +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Literal 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 SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, 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 +33,8 @@ def serena_config(): Language.PHP, Language.CSHARP, Language.CLOJURE, + Language.FSHARP, + Language.POWERSHELL, ]: repo_path = get_repo_path(language) if repo_path.exists(): @@ -53,12 +59,47 @@ def serena_config(): return config +def read_project_file(project: Project, relative_path: str) -> str: + """Utility function to read a file from the project.""" + file_path = os.path.join(project.project_root, relative_path) + with open(file_path, encoding=project.project_config.encoding) as f: + return f.read() + + +@contextmanager +def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]: + """Context manager to modify a project file and revert the changes after use.""" + project = serena_agent.get_active_project() + file_path = os.path.join(project.project_root, relative_path) + + # Read the original content + original_content = read_project_file(project, relative_path) + + try: + yield + finally: + # Revert to the original content + with open(file_path, "w", encoding=project.project_config.encoding) as f: + f.write(original_content) + + @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 shut down to free resources + agent.shutdown(timeout=5) class TestSerenaAgent: @@ -72,14 +113,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 +164,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 +323,144 @@ 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="") + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param( + Language.TYPESCRIPT, + marks=pytest.mark.typescript, + ), + ], + indirect=["serena_agent"], + ) + def test_replace_content_regex_with_wildcard_ok(self, serena_agent: SerenaAgent): + """ + Tests a regex-based content replacement that has a unique match + """ + relative_path = "ws_manager.js" + with project_file_modification_context(serena_agent, relative_path): + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + result = replace_content_tool.apply( + needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}', + repl='catch(error) { console.log("Never mind"); }', + relative_path=relative_path, + mode="regex", + ) + assert result == SUCCESS_RESULT + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param( + Language.TYPESCRIPT, + marks=pytest.mark.typescript, + ), + ], + indirect=["serena_agent"], + ) + @pytest.mark.parametrize("mode", ["literal", "regex"]) + def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]): + """ + Tests a content replacement where the needle and replacement strings contain backslashes. + This is a regression test for escaping issues. + """ + relative_path = "ws_manager.js" + needle = r'console.log("WebSocketManager initializing\nStatus OK");' + repl = r'console.log("WebSocketManager initialized\nAll systems go!");' + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + mode: Literal["literal", "regex"] + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + needle=re.escape(needle) if mode == "regex" else needle, + repl=repl, + relative_path=relative_path, + mode=mode, + ) + assert result == SUCCESS_RESULT + new_content = read_project_file(serena_agent.get_active_project(), relative_path) + assert repl in new_content + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param( + Language.TYPESCRIPT, + marks=pytest.mark.typescript, + ), + ], + indirect=["serena_agent"], + ) + def test_replace_content_regex_with_wildcard_ambiguous(self, serena_agent: SerenaAgent): + """ + Tests that an ambiguous replacement where there is a larger match that internally contains + a smaller match triggers an exception + """ + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + with pytest.raises(ValueError, match="ambiguous"): + replace_content_tool.apply( + needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}', + repl='catch(error) { console.log("Never mind"); }', + relative_path="ws_manager.js", + mode="regex", + ) diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py index 6a56a30c..b885c558 100644 --- a/test/serena/test_symbol_editing.py +++ b/test/serena/test_symbol_editing.py @@ -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) diff --git a/test/solidlsp/al/test_al_basic.py b/test/solidlsp/al/test_al_basic.py index 47e859ca..8b4fb7a7 100644 --- a/test/solidlsp/al/test_al_basic.py +++ b/test/solidlsp/al/test_al_basic.py @@ -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 diff --git a/test/solidlsp/clojure/__init__.py b/test/solidlsp/clojure/__init__.py index be945a53..f7d9dad6 100644 --- a/test/solidlsp/clojure/__init__.py +++ b/test/solidlsp/clojure/__init__.py @@ -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 diff --git a/test/solidlsp/clojure/test_clojure_basic.py b/test/solidlsp/clojure/test_clojure_basic.py index c4037e54..1a199673 100644 --- a/test/solidlsp/clojure/test_clojure_basic.py +++ b/test/solidlsp/clojure/test_clojure_basic.py @@ -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): diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 21ec4146..5bd7beb1 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -5,7 +5,9 @@ from typing import cast from unittest.mock import Mock, patch import pytest +from sensai.util import logging +from serena.util.logging import SuspendedLoggersContext from solidlsp import SolidLanguageServer from solidlsp.language_servers.csharp_language_server import ( CSharpLanguageServer, @@ -247,8 +249,6 @@ class TestCSharpSolutionProjectOpening: solution_file = temp_path / "TestSolution.sln" solution_file.touch() - # Mock logger to capture log messages - mock_logger = Mock() mock_config = Mock(spec=LanguageServerConfig) mock_config.ignored_paths = [] @@ -256,10 +256,15 @@ class TestCSharpSolutionProjectOpening: mock_settings = Mock(spec=SolidLSPSettings) mock_settings.ls_resources_dir = "/tmp/test_ls_resources" mock_settings.project_data_relative_path = "project_data" - CSharpLanguageServer(mock_config, mock_logger, str(temp_path), mock_settings) - # Verify that logger was called with solution file discovery - mock_logger.log.assert_any_call(f"Found solution/project file: {solution_file}", 20) # logging.INFO + with SuspendedLoggersContext(): + logging.getLogger().setLevel(logging.DEBUG) + with logging.MemoryLoggerContext() as mem_log: + CSharpLanguageServer(mock_config, str(temp_path), mock_settings) + + # Verify that logger was called with solution file discovery + expected_log_msg = f"Found solution/project file: {solution_file}" + assert expected_log_msg in mem_log.get_log() @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._ensure_server_installed") @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer._start_server") @@ -273,20 +278,22 @@ class TestCSharpSolutionProjectOpening: temp_path = Path(temp_dir) # Mock logger to capture log messages - mock_logger = Mock() mock_config = Mock(spec=LanguageServerConfig) mock_config.ignored_paths = [] - # Create CSharpLanguageServer instance mock_settings = Mock(spec=SolidLSPSettings) mock_settings.ls_resources_dir = "/tmp/test_ls_resources" mock_settings.project_data_relative_path = "project_data" - CSharpLanguageServer(mock_config, mock_logger, str(temp_path), mock_settings) - # Verify that logger was called with warning about no solution/project files - mock_logger.log.assert_any_call( - "No .sln or .csproj file found, language server will attempt auto-discovery", 30 # logging.WARNING - ) + # Create CSharpLanguageServer instance + with SuspendedLoggersContext(): + logging.getLogger().setLevel(logging.DEBUG) + with logging.MemoryLoggerContext() as mem_log: + CSharpLanguageServer(mock_config, str(temp_path), mock_settings) + + # Verify that logger was called with warning about no solution/project files + expected_log_msg = "No .sln or .csproj file found, language server will attempt auto-discovery" + assert expected_log_msg in mem_log.get_log() def test_solution_and_project_opening_with_real_test_repo(self): """Test solution and project opening with the actual C# test repository.""" diff --git a/test/solidlsp/elixir/__init__.py b/test/solidlsp/elixir/__init__.py index 4d7e8860..f8532a0b 100644 --- a/test/solidlsp/elixir/__init__.py +++ b/test/solidlsp/elixir/__init__.py @@ -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) diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py index 44bf7378..fe18cd34 100644 --- a/test/solidlsp/elixir/test_elixir_basic.py +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -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: diff --git a/test/solidlsp/elixir/test_elixir_ignored_dirs.py b/test/solidlsp/elixir/test_elixir_ignored_dirs.py index 14f70dfd..d74c58d2 100644 --- a/test/solidlsp/elixir/test_elixir_ignored_dirs.py +++ b/test/solidlsp/elixir/test_elixir_ignored_dirs.py @@ -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.""" diff --git a/test/solidlsp/elixir/test_elixir_integration.py b/test/solidlsp/elixir/test_elixir_integration.py index 553d2569..eff618b2 100644 --- a/test/solidlsp/elixir/test_elixir_integration.py +++ b/test/solidlsp/elixir/test_elixir_integration.py @@ -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: diff --git a/test/solidlsp/elixir/test_elixir_symbol_retrieval.py b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py index b8c058cc..df5869f7 100644 --- a/test/solidlsp/elixir/test_elixir_symbol_retrieval.py +++ b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py @@ -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: @@ -251,6 +257,7 @@ class TestElixirLanguageServerSymbols: # We should find some references or none (both are valid outcomes) assert isinstance(refs, list) + @pytest.mark.xfail(reason="Flaky test, sometimes fails with an Expert-internal error") @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: """Test that symbol tree structure is correctly built.""" @@ -271,7 +278,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 +291,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 +305,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) diff --git a/test/solidlsp/erlang/test_erlang_ignored_dirs.py b/test/solidlsp/erlang/test_erlang_ignored_dirs.py index 6b9ee69c..74cf87ff 100644 --- a/test/solidlsp/erlang/test_erlang_ignored_dirs.py +++ b/test/solidlsp/erlang/test_erlang_ignored_dirs.py @@ -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) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py new file mode 100644 index 00000000..15de4ad7 --- /dev/null +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -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 diff --git a/test/solidlsp/groovy/test_groovy_basic.py b/test/solidlsp/groovy/test_groovy_basic.py new file mode 100644 index 00000000..0ebae30c --- /dev/null +++ b/test/solidlsp/groovy/test_groovy_basic.py @@ -0,0 +1,100 @@ +import os +from pathlib import Path + +import pytest + +from serena.constants import SERENA_MANAGED_DIR_NAME +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_utils import SymbolUtils +from solidlsp.settings import SolidLSPSettings + + +@pytest.mark.groovy +class TestGroovyLanguageServer: + @classmethod + def setup_class(cls): + """ + Set up test class with Groovy test repository. + """ + cls.test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "groovy" / "test_repo" + + if not cls.test_repo_path.exists(): + pytest.skip("Groovy test repository not found") + + # Use JAR path from environment variable + ls_jar_path = os.environ.get("GROOVY_LS_JAR_PATH") + if not ls_jar_path or not os.path.exists(ls_jar_path): + pytest.skip( + "Groovy Language Server JAR not found. Set GROOVY_LS_JAR_PATH environment variable to run tests.", + allow_module_level=True, + ) + + # Get JAR options from environment variable + ls_jar_options = os.environ.get("GROOVY_LS_JAR_OPTIONS", "") + ls_java_home_path = os.environ.get("GROOVY_LS_JAVA_HOME_PATH") + + groovy_settings = {"ls_jar_path": ls_jar_path, "ls_jar_options": ls_jar_options} + if ls_java_home_path: + groovy_settings["ls_java_home_path"] = ls_java_home_path + + # Create language server directly with Groovy-specific settings + repo_path = str(cls.test_repo_path) + config = LanguageServerConfig(code_language=Language.GROOVY, ignored_paths=[], trace_lsp_communication=False) + + solidlsp_settings = SolidLSPSettings( + solidlsp_dir=str(Path.home() / ".serena"), + project_data_relative_path=SERENA_MANAGED_DIR_NAME, + ls_specific_settings={Language.GROOVY: groovy_settings}, + ) + + cls.language_server = SolidLanguageServer.create(config, repo_path, solidlsp_settings=solidlsp_settings) + cls.language_server.start() + + @classmethod + def teardown_class(cls): + """ + Clean up language server. + """ + if hasattr(cls, "language_server"): + cls.language_server.stop() + + def test_find_symbol(self) -> None: + symbols = self.language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model class not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser class not found in symbol tree" + + def test_find_referencing_class_symbols(self) -> None: + file_path = os.path.join("src", "main", "groovy", "com", "example", "Utils.groovy") + refs = self.language_server.request_references(file_path, 3, 6) + assert any("Main.groovy" in ref.get("relativePath", "") for ref in refs), "Utils should be referenced from Main.groovy" + + file_path = os.path.join("src", "main", "groovy", "com", "example", "Model.groovy") + symbols = self.language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + model_symbol = None + for sym in symbols[0]: + if sym.get("name") == "com.example.Model" and sym.get("kind") == 5: + model_symbol = sym + break + assert model_symbol is not None, "Could not find 'Model' class symbol in Model.groovy" + + if "selectionRange" in model_symbol: + sel_start = model_symbol["selectionRange"]["start"] + else: + sel_start = model_symbol["range"]["start"] + refs = self.language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + main_refs = [ref for ref in refs if "Main.groovy" in ref.get("relativePath", "")] + assert len(main_refs) >= 2, f"Model should be referenced from Main.groovy at least 2 times, found {len(main_refs)}" + + model_user_refs = [ref for ref in refs if "ModelUser.groovy" in ref.get("relativePath", "")] + assert len(model_user_refs) >= 1, f"Model should be referenced from ModelUser.groovy at least 1 time, found {len(model_user_refs)}" + + def test_overview_methods(self) -> None: + symbols = self.language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "Main"), "Main missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Utils"), "Utils missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Model"), "Model missing from overview" + assert SymbolUtils.symbol_tree_contains_name(symbols, "ModelUser"), "ModelUser missing from overview" diff --git a/test/solidlsp/java/test_java_basic.py b/test/solidlsp/java/test_java_basic.py index 5b3bcd6a..57642bbe 100644 --- a/test/solidlsp/java/test_java_basic.py +++ b/test/solidlsp/java/test_java_basic.py @@ -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: diff --git a/test/solidlsp/powershell/__init__.py b/test/solidlsp/powershell/__init__.py new file mode 100644 index 00000000..3fb55130 --- /dev/null +++ b/test/solidlsp/powershell/__init__.py @@ -0,0 +1 @@ +# PowerShell language server tests diff --git a/test/solidlsp/powershell/test_powershell_basic.py b/test/solidlsp/powershell/test_powershell_basic.py new file mode 100644 index 00000000..0d8a7349 --- /dev/null +++ b/test/solidlsp/powershell/test_powershell_basic.py @@ -0,0 +1,174 @@ +""" +Basic integration tests for the PowerShell language server functionality. + +These tests validate the functionality of the language server APIs +like request_document_symbols using the PowerShell test repository. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.powershell +class TestPowerShellLanguageServerBasics: + """Test basic functionality of the PowerShell language server.""" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_language_server_initialization(self, language_server: SolidLanguageServer) -> None: + """Test that PowerShell language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.POWERSHELL + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for PowerShell files.""" + # Test getting symbols from main.ps1 + all_symbols, _root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() + + # Extract function symbols (LSP Symbol Kind 12) + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + function_names = [symbol["name"] for symbol in function_symbols] + + # PSES returns function names in format "function FuncName ()" - check for function name substring + def has_function(name: str) -> bool: + return any(name in fn for fn in function_names) + + # Should detect the main functions from main.ps1 + assert has_function("Greet-User"), f"Should find Greet-User function in {function_names}" + assert has_function("Process-Items"), f"Should find Process-Items function in {function_names}" + assert has_function("Main"), f"Should find Main function in {function_names}" + assert len(function_symbols) >= 3, f"Should find at least 3 functions, found {len(function_symbols)}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_utils_functions(self, language_server: SolidLanguageServer) -> None: + """Test function detection in utils.ps1 file.""" + # Test with utils.ps1 + utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.ps1").get_all_symbols_and_roots() + + utils_function_symbols = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] + utils_function_names = [symbol["name"] for symbol in utils_function_symbols] + + # PSES returns function names in format "function FuncName ()" - check for function name substring + def has_function(name: str) -> bool: + return any(name in fn for fn in utils_function_names) + + # Should detect functions from utils.ps1 + expected_utils_functions = [ + "Convert-ToUpperCase", + "Convert-ToLowerCase", + "Remove-Whitespace", + "Backup-File", + "Test-ArrayContains", + "Write-LogMessage", + "Test-ValidEmail", + "Test-IsNumber", + ] + + for func_name in expected_utils_functions: + assert has_function(func_name), f"Should find {func_name} function in utils.ps1, got {utils_function_names}" + + assert len(utils_function_symbols) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_function_symbols)}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_function_with_parameters(self, language_server: SolidLanguageServer) -> None: + """Test that functions with CmdletBinding and parameters are detected correctly.""" + all_symbols, _root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() + + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + + # Find Greet-User function which has parameters + # PSES returns function names in format "function FuncName ()" + greet_user_symbol = next((sym for sym in function_symbols if "Greet-User" in sym["name"]), None) + assert greet_user_symbol is not None, f"Should find Greet-User function in {[s['name'] for s in function_symbols]}" + + # Find Process-Items function which has array parameter + process_items_symbol = next((sym for sym in function_symbols if "Process-Items" in sym["name"]), None) + assert process_items_symbol is not None, f"Should find Process-Items function in {[s['name'] for s in function_symbols]}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_all_function_detection(self, language_server: SolidLanguageServer) -> None: + """Test that all expected functions are detected across both files.""" + # Get symbols from main.ps1 + main_all_symbols, _main_root_symbols = language_server.request_document_symbols("main.ps1").get_all_symbols_and_roots() + main_functions = [symbol for symbol in main_all_symbols if symbol.get("kind") == 12] + main_function_names = [func["name"] for func in main_functions] + + # Get symbols from utils.ps1 + utils_all_symbols, _utils_root_symbols = language_server.request_document_symbols("utils.ps1").get_all_symbols_and_roots() + utils_functions = [symbol for symbol in utils_all_symbols if symbol.get("kind") == 12] + utils_function_names = [func["name"] for func in utils_functions] + + # PSES returns function names in format "function FuncName ()" - check for function name substring + def has_main_function(name: str) -> bool: + return any(name in fn for fn in main_function_names) + + def has_utils_function(name: str) -> bool: + return any(name in fn for fn in utils_function_names) + + # Verify main.ps1 functions + expected_main = ["Greet-User", "Process-Items", "Main"] + for expected_func in expected_main: + assert has_main_function(expected_func), f"Should detect {expected_func} function in main.ps1, got {main_function_names}" + + # Verify utils.ps1 functions + expected_utils = [ + "Convert-ToUpperCase", + "Convert-ToLowerCase", + "Remove-Whitespace", + "Backup-File", + "Test-ArrayContains", + "Write-LogMessage", + "Test-ValidEmail", + "Test-IsNumber", + ] + for expected_func in expected_utils: + assert has_utils_function(expected_func), f"Should detect {expected_func} function in utils.ps1, got {utils_function_names}" + + # Verify total counts + assert len(main_functions) >= 3, f"Should find at least 3 functions in main.ps1, found {len(main_functions)}" + assert len(utils_functions) >= 8, f"Should find at least 8 functions in utils.ps1, found {len(utils_functions)}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test finding references to a function within the same file.""" + main_path = "main.ps1" + + # Get symbols to find the Greet-User function which is called from Main + all_symbols, _root_symbols = language_server.request_document_symbols(main_path).get_all_symbols_and_roots() + + # Find Greet-User function definition + function_symbols = [s for s in all_symbols if s.get("kind") == 12] + greet_user_symbol = next((s for s in function_symbols if "Greet-User" in s["name"]), None) + assert greet_user_symbol is not None, f"Should find Greet-User function in {[s['name'] for s in function_symbols]}" + + # Find references to Greet-User (should be called from Main function at line 91) + sel_start = greet_user_symbol["selectionRange"]["start"] + refs = language_server.request_references(main_path, sel_start["line"], sel_start["character"]) + + # Should find at least the call site in Main function + assert refs is not None and len(refs) >= 1, f"Should find references to Greet-User, got {refs}" + assert any( + "main.ps1" in ref.get("uri", ref.get("relativePath", "")) for ref in refs + ), f"Should find reference in main.ps1, got {refs}" + + @pytest.mark.parametrize("language_server", [Language.POWERSHELL], indirect=True) + def test_powershell_find_definition_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding definition of functions across files (main.ps1 -> utils.ps1).""" + # main.ps1 calls Convert-ToUpperCase from utils.ps1 at line 99 (0-indexed: 98) + # The call is: $upperName = Convert-ToUpperCase -InputString $User + # We'll request definition from the call site in main.ps1 + main_path = "main.ps1" + + # Find definition of Convert-ToUpperCase from its usage in main.ps1 + # Line 99 (1-indexed) = line 98 (0-indexed), character position ~16 for "Convert-ToUpperCase" + definition_locations = language_server.request_definition(main_path, 98, 18) + + # Should find the definition in utils.ps1 + assert ( + definition_locations is not None and len(definition_locations) >= 1 + ), f"Should find definition of Convert-ToUpperCase, got {definition_locations}" + assert any( + "utils.ps1" in loc.get("uri", "") for loc in definition_locations + ), f"Should find definition in utils.ps1, got {definition_locations}" diff --git a/test/solidlsp/python/test_retrieval_with_ignored_dirs.py b/test/solidlsp/python/test_retrieval_with_ignored_dirs.py index a3d7d6a2..031f185c 100644 --- a/test/solidlsp/python/test_retrieval_with_ignored_dirs.py +++ b/test/solidlsp/python/test_retrieval_with_ignored_dirs.py @@ -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 # This mark will be applied to all tests in this module pytestmark = pytest.mark.python @@ -15,12 +15,8 @@ pytestmark = pytest.mark.python def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]: """Fixture to set up an LS for the python test repo with the 'scripts' directory ignored.""" ignored_paths = ["scripts", "custom_test"] - ls = create_ls(ignored_paths=ignored_paths, language=Language.PYTHON) - ls.start() - try: + with start_ls_context(language=Language.PYTHON, ignored_paths=ignored_paths) as ls: yield ls - finally: - ls.stop() @pytest.mark.parametrize("ls_with_ignored_dirs", [Language.PYTHON], indirect=True) @@ -50,18 +46,17 @@ 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 = ["*ipts", "custom_t*"] - ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.PYTHON) - ls.start() - # same as in the above tests - root = ls.request_full_symbol_tree()[0] - root_children = root["children"] - children_names = {child["name"] for child in root_children} - assert children_names == {"test_repo", "examples"} + with start_ls_context(language=Language.PYTHON, 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"] + children_names = {child["name"] for child in root_children} + assert children_names == {"test_repo", "examples"} - # test that the refs and symbols with glob patterns are ignored - definition_file = "test_repo/models.py" - definition_line = 56 - definition_col = 6 + # test that the refs and symbols with glob patterns are ignored + definition_file = "test_repo/models.py" + definition_line = 56 + definition_col = 6 - references = ls.request_references(definition_file, definition_line, definition_col) - assert not any("scripts" in ref["relativePath"] for ref in references) + references = ls.request_references(definition_file, definition_line, definition_col) + assert not any("scripts" in ref["relativePath"] for ref in references) diff --git a/test/solidlsp/rust/test_rust_2024_edition.py b/test/solidlsp/rust/test_rust_2024_edition.py index 1facfd28..77eb084a 100644 --- a/test/solidlsp/rust/test_rust_2024_edition.py +++ b/test/solidlsp/rust/test_rust_2024_edition.py @@ -1,37 +1,36 @@ import os +from collections.abc import Iterator from pathlib import Path import pytest +from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils -from test.conftest import create_ls +from test.conftest import start_ls_context + + +@pytest.fixture(scope="class") +def rust_language_server() -> Iterator[SolidLanguageServer]: + """Set up the test class with the Rust 2024 edition test repository.""" + test_repo_2024_path = TestRust2024EditionLanguageServer.test_repo_2024_path + + if not test_repo_2024_path.exists(): + pytest.skip("Rust 2024 edition test repository not found") + + # Create and start the language server for the 2024 edition repo + with start_ls_context(Language.RUST, str(test_repo_2024_path)) as ls: + yield ls @pytest.mark.rust class TestRust2024EditionLanguageServer: - @classmethod - def setup_class(cls): - """Set up the test class with the Rust 2024 edition test repository.""" - cls.test_repo_2024_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "rust" / "test_repo_2024" + test_repo_2024_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "rust" / "test_repo_2024" - if not cls.test_repo_2024_path.exists(): - pytest.skip("Rust 2024 edition test repository not found") - - # Create and start the language server for the 2024 edition repo - cls.language_server = create_ls(Language.RUST, str(cls.test_repo_2024_path)) - cls.language_server.start() - - @classmethod - def teardown_class(cls): - """Clean up the language server.""" - if hasattr(cls, "language_server"): - cls.language_server.stop() - - def test_find_references_raw(self) -> None: + def test_find_references_raw(self, rust_language_server) -> None: # Test finding references to the 'add' function defined in main.rs file_path = os.path.join("src", "main.rs") - symbols = self.language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() add_symbol = None for sym in symbols[0]: if sym.get("name") == "add": @@ -39,21 +38,21 @@ class TestRust2024EditionLanguageServer: break assert add_symbol is not None, "Could not find 'add' function symbol in main.rs" sel_start = add_symbol["selectionRange"]["start"] - refs = self.language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + refs = rust_language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # The add function should be referenced within main.rs itself (in the main function) assert any("main.rs" in ref.get("relativePath", "") for ref in refs), "main.rs should reference add function" - def test_find_symbol(self) -> None: - symbols = self.language_server.request_full_symbol_tree() + def test_find_symbol(self, rust_language_server) -> None: + symbols = rust_language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function 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, "multiply"), "multiply function not found in symbol tree" assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator struct not found in symbol tree" - def test_find_referencing_symbols_multiply(self) -> None: + def test_find_referencing_symbols_multiply(self, rust_language_server) -> None: # Find references to 'multiply' function defined in lib.rs file_path = os.path.join("src", "lib.rs") - symbols = self.language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() multiply_symbol = None for sym in symbols[0]: if sym.get("name") == "multiply": @@ -61,15 +60,15 @@ class TestRust2024EditionLanguageServer: break assert multiply_symbol is not None, "Could not find 'multiply' function symbol in lib.rs" sel_start = multiply_symbol["selectionRange"]["start"] - refs = self.language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + refs = rust_language_server.request_references(file_path, sel_start["line"], sel_start["character"]) # The multiply function exists but may not be referenced anywhere, which is fine # This test just verifies we can find the symbol and request references without error assert isinstance(refs, list), "Should return a list of references (even if empty)" - def test_find_calculator_struct_and_impl(self) -> None: + def test_find_calculator_struct_and_impl(self, rust_language_server) -> None: # Test finding the Calculator struct and its impl block file_path = os.path.join("src", "lib.rs") - symbols = self.language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + symbols = rust_language_server.request_document_symbols(file_path).get_all_symbols_and_roots() # Find the Calculator struct calculator_struct = None @@ -95,8 +94,8 @@ class TestRust2024EditionLanguageServer: assert "add" in method_names, "Calculator impl should have 'add' method" assert "get_result" in method_names, "Calculator impl should have 'get_result' method" - def test_overview_methods(self) -> None: - symbols = self.language_server.request_full_symbol_tree() + def test_overview_methods(self, rust_language_server) -> None: + symbols = rust_language_server.request_full_symbol_tree() assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add missing from overview" assert SymbolUtils.symbol_tree_contains_name(symbols, "multiply"), "multiply missing from overview" diff --git a/test/solidlsp/scala/test_scala_language_server.py b/test/solidlsp/scala/test_scala_language_server.py index f22e2e96..9c498416 100644 --- a/test/solidlsp/scala/test_scala_language_server.py +++ b/test/solidlsp/scala/test_scala_language_server.py @@ -1,11 +1,9 @@ -import logging import os import pytest from solidlsp.language_servers.scala_language_server import ScalaLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.ls_logger import LanguageServerLogger from solidlsp.settings import SolidLSPSettings pytest.skip("Scala must be compiled for these tests to run through, which is a huge hassle", allow_module_level=True) @@ -18,10 +16,9 @@ pytestmark = pytest.mark.scala @pytest.fixture(scope="module") def scala_ls(): repo_root = os.path.abspath("test/resources/repos/scala") - logger = LanguageServerLogger(json_format=False, log_level=logging.INFO) config = LanguageServerConfig(code_language=Language.SCALA) solidlsp_settings = SolidLSPSettings() - ls = ScalaLanguageServer(config, logger, repo_root, solidlsp_settings) + ls = ScalaLanguageServer(config, repo_root, solidlsp_settings) with ls.start_server(): yield ls @@ -37,6 +34,10 @@ def test_scala_document_symbols(scala_ls): assert symbol_names[3] == "result" assert symbol_names[4] == "sum" assert symbol_names[5] == "add" + assert symbol_names[6] == "someMethod" + assert symbol_names[7] == "str" + assert symbol_names[8] == "Config" + assert symbol_names[9] == "field1" # confirm https://github.com/oraios/serena/issues/688 def test_scala_references_within_same_file(scala_ls): diff --git a/test/solidlsp/toml/__init__.py b/test/solidlsp/toml/__init__.py new file mode 100644 index 00000000..00ae6163 --- /dev/null +++ b/test/solidlsp/toml/__init__.py @@ -0,0 +1 @@ +"""TOML language server tests.""" diff --git a/test/solidlsp/toml/test_toml_basic.py b/test/solidlsp/toml/test_toml_basic.py new file mode 100644 index 00000000..db4ddcf6 --- /dev/null +++ b/test/solidlsp/toml/test_toml_basic.py @@ -0,0 +1,235 @@ +""" +Basic integration tests for the TOML language server functionality. + +These tests validate the functionality of the Taplo language server APIs +like request_document_symbols using the TOML test repository. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.toml +class TestTomlLanguageServerBasics: + """Test basic functionality of the TOML language server (Taplo).""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that TOML language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.TOML + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_cargo_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in Cargo.toml with specific symbol verification.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for Cargo.toml" + assert len(all_symbols) > 0, f"Should find symbols in Cargo.toml, found {len(all_symbols)}" + + # Verify specific top-level table names are detected + symbol_names = [sym.get("name") for sym in all_symbols] + assert "package" in symbol_names, "Should detect 'package' table in Cargo.toml" + assert "dependencies" in symbol_names, "Should detect 'dependencies' table in Cargo.toml" + assert "dev-dependencies" in symbol_names, "Should detect 'dev-dependencies' table in Cargo.toml" + assert "features" in symbol_names, "Should detect 'features' table in Cargo.toml" + assert "workspace" in symbol_names, "Should detect 'workspace' table in Cargo.toml" + + # Verify nested symbols exist (keys under 'package') + assert "name" in symbol_names, "Should detect nested 'name' key" + assert "version" in symbol_names, "Should detect nested 'version' key" + assert "edition" in symbol_names, "Should detect nested 'edition' key" + + # Check symbol kind for tables - Taplo uses kind 19 (object) for TOML tables + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + assert package_symbol.get("kind") == 19, "Top-level table should have kind 19 (object)" + + dependencies_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert dependencies_symbol is not None, "Should find 'dependencies' symbol" + assert dependencies_symbol.get("kind") == 19, "'dependencies' table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_pyproject_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for pyproject.toml" + assert len(all_symbols) > 0, f"Should find symbols in pyproject.toml, found {len(all_symbols)}" + + # Verify specific top-level table names + symbol_names = [sym.get("name") for sym in all_symbols] + assert "project" in symbol_names, "Should detect 'project' table" + assert "build-system" in symbol_names, "Should detect 'build-system' table" + + # Verify tool sections (nested tables) + # These could appear as 'tool' or 'tool.ruff' depending on Taplo's parsing + has_tool_section = any("tool" in name for name in symbol_names if name) + assert has_tool_section, "Should detect tool sections" + + # Verify nested keys under project + assert "name" in symbol_names, "Should detect 'name' under project" + assert "version" in symbol_names, "Should detect 'version' under project" + assert "requires-python" in symbol_names or "dependencies" in symbol_names, "Should detect project dependencies" + + # Check symbol kind for tables - Taplo uses kind 19 (object) for TOML tables + project_symbol = next((s for s in all_symbols if s.get("name") == "project"), None) + assert project_symbol is not None, "Should find 'project' symbol" + assert project_symbol.get("kind") == 19, "'project' table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbol_kinds(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that TOML symbols have appropriate LSP kinds for different value types.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + # Check boolean symbol kind (lto = true at line 22) + # LSP kind 17 = boolean + lto_symbol = next((s for s in all_symbols if s.get("name") == "lto"), None) + assert lto_symbol is not None, "Should find 'lto' boolean symbol" + assert lto_symbol.get("kind") == 17, "'lto' should have kind 17 (boolean)" + + # Check number symbol kind (opt-level = 3 at line 23) + # LSP kind 16 = number + opt_level_symbol = next((s for s in all_symbols if s.get("name") == "opt-level"), None) + assert opt_level_symbol is not None, "Should find 'opt-level' number symbol" + assert opt_level_symbol.get("kind") == 16, "'opt-level' should have kind 16 (number)" + + # Check string symbol kind (name = "test_project" at line 2) + # LSP kind 15 = string + name_symbols = [s for s in all_symbols if s.get("name") == "name"] + assert len(name_symbols) > 0, "Should find 'name' symbols" + # At least one should be a string + string_name_symbol = next((s for s in name_symbols if s.get("kind") == 15), None) + assert string_name_symbol is not None, "Should find 'name' with kind 15 (string)" + + # Check array symbol kind (default = ["feature1"] at line 17) + # LSP kind 18 = array + default_symbol = next((s for s in all_symbols if s.get("name") == "default"), None) + assert default_symbol is not None, "Should find 'default' array symbol" + assert default_symbol.get("kind") == 18, "'default' should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbols_with_body(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_symbols with body extraction.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for Cargo.toml" + assert len(all_symbols) > 0, "Should have symbols" + + # Find the 'package' symbol and verify its body + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + + # Check that body exists and contains expected content + # Note: Taplo includes the section header in the body + assert "body" in package_symbol, "'package' symbol should have body" + package_body = package_symbol["body"] + assert 'name = "test_project"' in package_body, "Body should contain 'name' field" + assert 'version = "0.1.0"' in package_body, "Body should contain 'version' field" + assert 'edition = "2021"' in package_body, "Body should contain 'edition' field" + + # Find the dependencies symbol and check its body + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None, "Should find 'dependencies' symbol" + assert "body" in deps_symbol, "'dependencies' symbol should have body" + deps_body = deps_symbol["body"] + assert "serde" in deps_body, "Body should contain serde dependency" + assert "tokio" in deps_body, "Body should contain tokio dependency" + + # Find the top-level [features] section (not the nested 'features' in serde dependency) + # The [features] section should be kind 19 (object) and at line 15 (0-indexed) + features_symbols = [s for s in all_symbols if s.get("name") == "features"] + # Find the top-level one - should be kind 19 (object) with children + features_symbol = next( + (s for s in features_symbols if s.get("kind") == 19 and s.get("children")), + None, + ) + assert features_symbol is not None, "Should find top-level 'features' table symbol" + assert "body" in features_symbol, "'features' symbol should have body" + features_body = features_symbol["body"] + assert "default" in features_body, "Body should contain 'default' feature" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbol_ranges(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbols have proper range information.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + # Check the 'package' symbol range - should start at line 0 (0-indexed, actual line 1) + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + assert "range" in package_symbol, "'package' symbol should have range" + + package_range = package_symbol["range"] + assert "start" in package_range, "Range should have start" + assert "end" in package_range, "Range should have end" + assert package_range["start"]["line"] == 0, "'package' should start at line 0 (0-indexed, actual line 1)" + # Package block spans from line 1 to line 7 in file (1-indexed) + # In 0-indexed LSP coordinates: line 0 (start) to line 6 or 7 (end) + assert package_range["end"]["line"] >= 6, "'package' should end at or after line 6 (0-indexed)" + + # Check a nested symbol range - 'name' under package at line 2 (1-indexed), line 1 (0-indexed) + name_symbols = [s for s in all_symbols if s.get("name") == "name"] + assert len(name_symbols) > 0, "Should find 'name' symbols" + # Find the one under 'package' (should be at line 1 in 0-indexed) + package_name = next((s for s in name_symbols if s["range"]["start"]["line"] == 1), None) + assert package_name is not None, "Should find 'name' under 'package'" + + # Check the dependencies range - starts at line 9 (1-indexed), line 8 (0-indexed) + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None, "Should find 'dependencies' symbol" + deps_range = deps_symbol["range"] + assert deps_range["start"]["line"] == 8, "'dependencies' should start at line 8 (0-indexed, actual line 9)" + + # Check that range includes line and character positions + assert "line" in package_range["start"], "Start should have line" + assert "character" in package_range["start"], "Start should have character" + assert "line" in package_range["end"], "End should have line" + assert "character" in package_range["end"], "End should have character" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_nested_table_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of nested table symbols like profile.release and tool.ruff.""" + # Test Cargo.toml for profile.release + cargo_symbols, _ = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert cargo_symbols is not None + symbol_names = [sym.get("name") for sym in cargo_symbols] + + # Should detect profile.release or profile section + has_profile = any("profile" in name for name in symbol_names if name) + assert has_profile, "Should detect profile section in Cargo.toml" + + # Test pyproject.toml for tool sections + pyproject_symbols, _ = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + assert pyproject_symbols is not None + pyproject_names = [sym.get("name") for sym in pyproject_symbols] + + # Should detect tool.ruff, tool.mypy sections + has_ruff = any("ruff" in name for name in pyproject_names if name) + has_mypy = any("mypy" in name for name in pyproject_names if name) + assert has_ruff or has_mypy, "Should detect tool sections in pyproject.toml" + + # Verify pyproject has expected boolean: strict = true + strict_symbol = next((s for s in pyproject_symbols if s.get("name") == "strict"), None) + if strict_symbol: + assert strict_symbol.get("kind") == 17, "'strict' should have kind 17 (boolean)" diff --git a/test/solidlsp/toml/test_toml_edge_cases.py b/test/solidlsp/toml/test_toml_edge_cases.py new file mode 100644 index 00000000..3f651682 --- /dev/null +++ b/test/solidlsp/toml/test_toml_edge_cases.py @@ -0,0 +1,273 @@ +""" +Tests for TOML language server edge cases and advanced features. + +These tests cover: +- Inline tables +- Multiline strings +- Arrays of tables +- Nested tables +- Various TOML data types +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +class TestTomlEdgeCases: + """Test TOML language server handling of edge cases and advanced features.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_inline_table_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that inline tables are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + symbol_names = [sym.get("name") for sym in all_symbols] + + # The inline table 'endpoint' should be detected + assert "endpoint" in symbol_names, "Should detect 'endpoint' inline table" + + # Find the endpoint symbol and check its properties + endpoint_symbol = next((s for s in all_symbols if s.get("name") == "endpoint"), None) + assert endpoint_symbol is not None + # Inline tables should be kind 19 (object) + assert endpoint_symbol.get("kind") == 19, "Inline table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_nested_table_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that deeply nested tables are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect nested tables like server.ssl and database.pool + has_ssl = any("ssl" in str(name).lower() for name in symbol_names if name) + has_pool = any("pool" in str(name).lower() for name in symbol_names if name) + + assert has_ssl, f"Should detect 'server.ssl' nested table, got: {symbol_names}" + assert has_pool, f"Should detect 'database.pool' nested table, got: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_of_tables_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that [[array_of_tables]] syntax is properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect [[endpoints]] array of tables + assert "endpoints" in symbol_names, f"Should detect '[[endpoints]]' array of tables, got: {symbol_names}" + + # Find the endpoints symbol + endpoints_symbol = next((s for s in all_symbols if s.get("name") == "endpoints"), None) + assert endpoints_symbol is not None + + # Array of tables should be kind 18 (array) + assert endpoints_symbol.get("kind") == 18, "Array of tables should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_multiline_string_handling(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that multiline strings are handled correctly.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect connection_string and multiline fields + assert "connection_string" in symbol_names, "Should detect 'connection_string' with multiline value" + assert "multiline" in symbol_names, "Should detect 'multiline' literal string" + + # Find connection_string and verify it's a string type + conn_symbol = next((s for s in all_symbols if s.get("name") == "connection_string"), None) + assert conn_symbol is not None + # String type should be kind 15 + assert conn_symbol.get("kind") == 15, "Multiline string should have kind 15 (string)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that array values are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect 'outputs' and 'methods' arrays + assert "outputs" in symbol_names, "Should detect 'outputs' array" + assert "methods" in symbol_names, "Should detect 'methods' array" + + # Find outputs array and verify kind + outputs_symbol = next((s for s in all_symbols if s.get("name") == "outputs"), None) + assert outputs_symbol is not None + # Arrays should have kind 18 + assert outputs_symbol.get("kind") == 18, "'outputs' should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_float_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that float values are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect 'timeout' which has a float value (30.5) + assert "timeout" in symbol_names, "Should detect 'timeout' float value" + + # Find timeout and verify it's a number + timeout_symbol = next((s for s in all_symbols if s.get("name") == "timeout"), None) + assert timeout_symbol is not None + # Numbers should have kind 16 + assert timeout_symbol.get("kind") == 16, "'timeout' should have kind 16 (number)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_datetime_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that datetime values are detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect metadata section with datetime values + assert "metadata" in symbol_names, "Should detect 'metadata' section" + assert "created" in symbol_names, "Should detect 'created' datetime field" + assert "updated" in symbol_names, "Should detect 'updated' datetime field" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_body_with_inline_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol bodies include inline table content.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + # Find the endpoint symbol with body + endpoint_symbol = next((s for s in all_symbols if s.get("name") == "endpoint"), None) + assert endpoint_symbol is not None + + if "body" in endpoint_symbol: + body = endpoint_symbol["body"] + # Body should contain the inline table syntax + assert "url" in body or "version" in body, f"Body should contain inline table contents, got: {body}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_ranges_in_config(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol ranges are correct in config.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + # Find the server symbol + server_symbol = next((s for s in all_symbols if s.get("name") == "server"), None) + assert server_symbol is not None + assert "range" in server_symbol + + # Server should start near the beginning (line 2 is [server], 0-indexed: line 2) + server_range = server_symbol["range"] + assert server_range["start"]["line"] >= 0, "Server should start at or near the beginning" + assert server_range["end"]["line"] > server_range["start"]["line"], "Server block should span multiple lines" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_comment_handling(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that comments don't interfere with symbol detection.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # File has comments but symbols should still be detected correctly + expected_sections = {"server", "database", "logging", "endpoints", "metadata", "messages"} + found_sections = expected_sections.intersection(set(symbol_names)) + + assert len(found_sections) >= 4, f"Should find most sections despite comments, found: {found_sections}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_special_characters_in_strings(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that strings with escape sequences are handled.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect the messages section with special strings + assert "messages" in symbol_names, "Should detect 'messages' section" + assert "with_escapes" in symbol_names, "Should detect 'with_escapes' field" + assert "welcome" in symbol_names, "Should detect 'welcome' field" + + +class TestTomlDependencyTables: + """Test handling of dependency-style tables common in Cargo.toml and pyproject.toml.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_complex_dependency_inline_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of complex inline table dependencies like serde = { version = "1.0", features = ["derive"] }.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect serde and tokio dependencies + assert "serde" in symbol_names, "Should detect 'serde' dependency" + assert "tokio" in symbol_names, "Should detect 'tokio' dependency" + + # Find serde symbol + serde_symbol = next((s for s in all_symbols if s.get("name") == "serde"), None) + assert serde_symbol is not None + + # Dependency with inline table should be kind 19 (object) + assert serde_symbol.get("kind") == 19, "Complex dependency should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_simple_dependency_string(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of simple string dependencies like proptest = "1.0".""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect proptest dev-dependency + assert "proptest" in symbol_names, "Should detect 'proptest' dependency" + + # Find proptest symbol + proptest_symbol = next((s for s in all_symbols if s.get("name") == "proptest"), None) + assert proptest_symbol is not None + + # Simple string dependency should be kind 15 (string) + assert proptest_symbol.get("kind") == 15, "Simple string dependency should have kind 15 (string)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_pyproject_dependencies_array(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of pyproject.toml dependencies array.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect dependencies array + assert "dependencies" in symbol_names, "Should detect 'dependencies' array" + + # Find dependencies symbol + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None + + # Dependencies array should be kind 18 (array) + assert deps_symbol.get("kind") == 18, "Dependencies array should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_optional_dependencies_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of optional-dependencies in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect optional-dependencies or its nested form + has_optional_deps = any("optional" in str(name).lower() for name in symbol_names if name) + has_dev = "dev" in symbol_names + + assert has_optional_deps or has_dev, f"Should detect optional-dependencies or dev group, got: {symbol_names}" diff --git a/test/solidlsp/toml/test_toml_ignored_dirs.py b/test/solidlsp/toml/test_toml_ignored_dirs.py new file mode 100644 index 00000000..e5126942 --- /dev/null +++ b/test/solidlsp/toml/test_toml_ignored_dirs.py @@ -0,0 +1,60 @@ +""" +Tests for TOML language server directory ignoring functionality. + +These tests validate that the Taplo language server correctly ignores +TOML-specific directories like target, .cargo, and node_modules. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +@pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) +class TestTomlIgnoredDirectories: + """Test TOML-specific directory ignoring behavior.""" + + def test_default_ignored_directories(self, language_server: SolidLanguageServer) -> None: + """Test that default TOML directories are ignored.""" + # Test that TOML/Rust/Node-specific directories are ignored by default + assert language_server.is_ignored_dirname("target"), "target should be ignored" + assert language_server.is_ignored_dirname(".cargo"), ".cargo should be ignored" + assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" + + # Directories starting with . are ignored by base class + assert language_server.is_ignored_dirname(".git"), ".git should be ignored" + assert language_server.is_ignored_dirname(".venv"), ".venv should be ignored" + + def test_important_directories_not_ignored(self, language_server: SolidLanguageServer) -> None: + """Test that important directories are not ignored.""" + # Common project directories should not be ignored + assert not language_server.is_ignored_dirname("src"), "src should not be ignored" + assert not language_server.is_ignored_dirname("crates"), "crates should not be ignored" + assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored" + assert not language_server.is_ignored_dirname("tests"), "tests should not be ignored" + assert not language_server.is_ignored_dirname("config"), "config should not be ignored" + + def test_cargo_related_directories(self, language_server: SolidLanguageServer) -> None: + """Test Cargo/Rust-related directory handling.""" + # Rust build directories should be ignored + assert language_server.is_ignored_dirname("target"), "target (Rust build) should be ignored" + assert language_server.is_ignored_dirname(".cargo"), ".cargo should be ignored" + + # But important Rust directories should not be ignored + assert not language_server.is_ignored_dirname("benches"), "benches should not be ignored" + assert not language_server.is_ignored_dirname("examples"), "examples should not be ignored" + + def test_various_cache_directories(self, language_server: SolidLanguageServer) -> None: + """Test various cache and temporary directories are ignored.""" + # Directories starting with . are ignored by base class + assert language_server.is_ignored_dirname(".cache"), ".cache should be ignored" + + # IDE directories (start with .) + assert language_server.is_ignored_dirname(".idea"), ".idea should be ignored" + assert language_server.is_ignored_dirname(".vscode"), ".vscode should be ignored" + + # Note: __pycache__ is NOT ignored by TOML server (only Python servers ignore it) + assert not language_server.is_ignored_dirname("__pycache__"), "__pycache__ is not TOML-specific" diff --git a/test/solidlsp/toml/test_toml_symbol_retrieval.py b/test/solidlsp/toml/test_toml_symbol_retrieval.py new file mode 100644 index 00000000..9458c51a --- /dev/null +++ b/test/solidlsp/toml/test_toml_symbol_retrieval.py @@ -0,0 +1,191 @@ +""" +Tests for TOML language server symbol retrieval functionality. + +These tests focus on advanced symbol operations: +- request_containing_symbol +- request_document_overview +- request_full_symbol_tree +- request_dir_overview +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +class TestTomlSymbolRetrieval: + """Test advanced symbol retrieval functionality for TOML files.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_containing_symbol_behavior(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_containing_symbol behavior for TOML files. + + Note: Taplo LSP doesn't support definition/containing symbol lookups for TOML files + since TOML is a configuration format, not code. This test verifies the behavior. + """ + # Line 2 (0-indexed: 1) is inside the [package] table + containing_symbol = language_server.request_containing_symbol("Cargo.toml", 1, 5) + + # Taplo doesn't support containing symbol lookup - returns None + # This is expected behavior for a configuration file format + assert containing_symbol is None, "TOML LSP doesn't support containing symbol lookup" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_document_overview_cargo(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_overview for Cargo.toml.""" + overview = language_server.request_document_overview("Cargo.toml") + + assert overview is not None + assert len(overview) > 0 + + # Get symbol names from overview + symbol_names = {symbol.get("name") for symbol in overview if "name" in symbol} + + # Verify expected top-level tables appear + expected_tables = {"package", "dependencies", "dev-dependencies", "features", "workspace"} + assert expected_tables.issubset(symbol_names), f"Missing expected tables in overview: {expected_tables - symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_document_overview_pyproject(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_overview for pyproject.toml.""" + overview = language_server.request_document_overview("pyproject.toml") + + assert overview is not None + assert len(overview) > 0 + + # Get symbol names from overview + symbol_names = {symbol.get("name") for symbol in overview if "name" in symbol} + + # Verify expected top-level tables appear + assert "project" in symbol_names, "Should detect 'project' table" + assert "build-system" in symbol_names, "Should detect 'build-system' table" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_full_symbol_tree(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_full_symbol_tree returns TOML files.""" + symbol_tree = language_server.request_full_symbol_tree() + + assert symbol_tree is not None + assert len(symbol_tree) > 0 + + # The root should be test_repo + root = symbol_tree[0] + assert root["name"] == "test_repo" + assert "children" in root + + # Children should include TOML files + child_names = {child["name"] for child in root.get("children", [])} + # Note: File names are stripped of extension in some cases + assert ( + "Cargo" in child_names or "Cargo.toml" in child_names or any("cargo" in name.lower() for name in child_names) + ), f"Should find Cargo.toml in tree, got: {child_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_dir_overview(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_dir_overview returns symbols for TOML files.""" + overview = language_server.request_dir_overview(".") + + assert overview is not None + assert len(overview) > 0 + + # Should have entries for both Cargo.toml and pyproject.toml + file_paths = list(overview.keys()) + assert any("Cargo.toml" in path for path in file_paths), f"Should find Cargo.toml in overview, got: {file_paths}" + assert any("pyproject.toml" in path for path in file_paths), f"Should find pyproject.toml in overview, got: {file_paths}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_hierarchy_in_cargo(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol hierarchy is properly preserved in Cargo.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + # Find the 'package' table + package_symbol = next((s for s in root_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' as root symbol" + + # Verify it has children (nested keys) + assert "children" in package_symbol, "'package' should have children" + child_names = {child.get("name") for child in package_symbol.get("children", [])} + + # Package should have name, version, edition at minimum + assert "name" in child_names, "'package' should have 'name' child" + assert "version" in child_names, "'package' should have 'version' child" + assert "edition" in child_names, "'package' should have 'edition' child" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_hierarchy_in_pyproject(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol hierarchy is properly preserved in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + # Find the 'project' table + project_symbol = next((s for s in root_symbols if s.get("name") == "project"), None) + assert project_symbol is not None, "Should find 'project' as root symbol" + + # Verify it has children + assert "children" in project_symbol, "'project' should have children" + child_names = {child.get("name") for child in project_symbol.get("children", [])} + + # Project should have name, version, dependencies at minimum + assert "name" in child_names, "'project' should have 'name' child" + assert "version" in child_names, "'project' should have 'version' child" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_tool_section_hierarchy(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that tool sections in pyproject.toml are properly structured.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + # Get all symbol names + all_names = [s.get("name") for s in all_symbols] + + # Should detect tool.ruff, tool.mypy, or tool.pytest + has_ruff = any("ruff" in name.lower() for name in all_names if name) + has_mypy = any("mypy" in name.lower() for name in all_names if name) + has_pytest = any("pytest" in name.lower() for name in all_names if name) + + assert has_ruff or has_mypy or has_pytest, f"Should detect tool sections, got names: {all_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_of_tables_symbol(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that [[bin]] array of tables is detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + # Get all symbol names + all_names = [s.get("name") for s in all_symbols] + + # Should detect bin array of tables + has_bin = "bin" in all_names + assert has_bin, f"Should detect [[bin]] array of tables, got names: {all_names}" + + # Find the bin symbol and verify its structure + bin_symbol = next((s for s in all_symbols if s.get("name") == "bin"), None) + assert bin_symbol is not None, "Should find bin symbol" + + # Array of tables should be kind 18 (array) + assert bin_symbol.get("kind") == 18, "[[bin]] should have kind 18 (array)" + + # Children of array of tables are indexed by position ('0', '1', etc.) + if "children" in bin_symbol: + bin_children = bin_symbol.get("children", []) + assert len(bin_children) > 0, "[[bin]] should have at least one child element" + # First child is index '0' + first_child = bin_children[0] + assert first_child.get("name") == "0", f"First array element should be named '0', got: {first_child.get('name')}" + + # The '0' element should contain name and path as grandchildren + if "children" in first_child: + grandchild_names = {gc.get("name") for gc in first_child.get("children", [])} + assert "name" in grandchild_names, f"[[bin]] element should have 'name' field, got: {grandchild_names}" + assert "path" in grandchild_names, f"[[bin]] element should have 'path' field, got: {grandchild_names}" diff --git a/test/solidlsp/vue/__init__.py b/test/solidlsp/vue/__init__.py new file mode 100644 index 00000000..a587a13a --- /dev/null +++ b/test/solidlsp/vue/__init__.py @@ -0,0 +1,15 @@ +"""Vue language server tests.""" + +import shutil + + +def _test_npm_available() -> str: + """Test if npm is available and return error reason if not.""" + # Check if npm is installed + if not shutil.which("npm"): + return "npm is not installed or not in PATH" + return "" # No error, npm is available + + +NPM_UNAVAILABLE_REASON = _test_npm_available() +NPM_UNAVAILABLE = bool(NPM_UNAVAILABLE_REASON) diff --git a/test/solidlsp/vue/test_vue_basic.py b/test/solidlsp/vue/test_vue_basic.py new file mode 100644 index 00000000..c46f35aa --- /dev/null +++ b/test/solidlsp/vue/test_vue_basic.py @@ -0,0 +1,362 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +@pytest.mark.vue +class TestVueLanguageServer: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_vue_files_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "App"), "App not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorButton"), "CalculatorButton not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorInput"), "CalculatorInput not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorDisplay"), "CalculatorDisplay not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + # Find useCalculatorStore function + store_symbol = None + for sym in symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + assert store_symbol is not None, "useCalculatorStore function not found" + + # Get references + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Should have multiple references: definition + usage in App.vue, CalculatorInput.vue, CalculatorDisplay.vue + assert len(refs) >= 4, f"useCalculatorStore should have at least 4 references (definition + 3 usages), got {len(refs)}" + + # Verify we have references from .vue files + vue_refs = [ref for ref in refs if ".vue" in ref.get("relativePath", "")] + assert len(vue_refs) >= 3, f"Should have at least 3 Vue component references, got {len(vue_refs)}" + + +@pytest.mark.vue +class TestVueDualLspArchitecture: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_typescript_server_coordination(self, language_server: SolidLanguageServer) -> None: + ts_file = os.path.join("src", "stores", "calculator.ts") + ts_symbols = language_server.request_document_symbols(ts_file).get_all_symbols_and_roots() + ts_symbol_names = [s.get("name") for s in ts_symbols[0]] + + assert len(ts_symbols[0]) >= 5, f"TypeScript server should return multiple symbols for calculator.ts, got {len(ts_symbols[0])}" + assert "useCalculatorStore" in ts_symbol_names, "TypeScript server should extract store function" + + # Verify Vue server can parse .vue files + vue_file = os.path.join("src", "App.vue") + vue_symbols = language_server.request_document_symbols(vue_file).get_all_symbols_and_roots() + vue_symbol_names = [s.get("name") for s in vue_symbols[0]] + + assert len(vue_symbols[0]) >= 15, f"Vue server should return at least 15 symbols for App.vue, got {len(vue_symbols[0])}" + assert "appTitle" in vue_symbol_names, "Vue server should extract ref declarations from script setup" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_cross_file_references_vue_to_typescript(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + store_symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + store_symbol = None + for sym in store_symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + if not store_symbol or "selectionRange" not in store_symbol: + pytest.skip("useCalculatorStore symbol not found - test fixture may need updating") + + # Request references for this symbol + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Verify we found references: definition + usage in App.vue, CalculatorInput.vue, CalculatorDisplay.vue + assert len(refs) >= 4, f"useCalculatorStore should have at least 4 references (definition + 3 usages), found {len(refs)} references" + + # Verify references include .vue files (components that import the store) + vue_refs = [ref for ref in refs if ".vue" in ref.get("uri", "")] + assert ( + len(vue_refs) >= 3 + ), f"Should find at least 3 references in Vue components, found {len(vue_refs)}: {[ref.get('uri', '') for ref in vue_refs]}" + + # Verify specific components that use the store + expected_vue_files = ["App.vue", "CalculatorInput.vue", "CalculatorDisplay.vue"] + found_components = [] + for expected_file in expected_vue_files: + matching_refs = [ref for ref in vue_refs if expected_file in ref.get("uri", "")] + if matching_refs: + found_components.append(expected_file) + + assert len(found_components) > 0, ( + f"Should find references in at least one component that uses the store. " + f"Expected any of {expected_vue_files}, found references in: {[ref.get('uri', '') for ref in vue_refs]}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_cross_file_references_typescript_to_vue(self, language_server: SolidLanguageServer) -> None: + types_file = os.path.join("src", "types", "index.ts") + types_symbols = language_server.request_document_symbols(types_file).get_all_symbols_and_roots() + types_symbol_names = [s.get("name") for s in types_symbols[0]] + + # Operation type is used in calculator.ts and CalculatorInput.vue + assert "Operation" in types_symbol_names, "Operation type should exist in types file" + + operation_symbol = None + for sym in types_symbols[0]: + if sym.get("name") == "Operation": + operation_symbol = sym + break + + if not operation_symbol or "selectionRange" not in operation_symbol: + pytest.skip("Operation type symbol not found - test fixture may need updating") + + # Request references for the Operation type + sel_start = operation_symbol["selectionRange"]["start"] + refs = language_server.request_references(types_file, sel_start["line"], sel_start["character"]) + + # Verify we found references: definition + usage in calculator.ts and Vue files + assert len(refs) >= 2, f"Operation type should have at least 2 references (definition + usages), found {len(refs)} references" + + # The Operation type should be referenced in both .ts files (calculator.ts) and potentially .vue files + all_ref_uris = [ref.get("uri", "") for ref in refs] + has_ts_refs = any(".ts" in uri and "types" not in uri for uri in all_ref_uris) + + assert ( + has_ts_refs + ), f"Operation type should be referenced in TypeScript files like calculator.ts. Found references in: {all_ref_uris}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_reference_deduplication(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + store_symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + # Find a commonly-used symbol (useCalculatorStore) + store_symbol = None + for sym in store_symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + if not store_symbol or "selectionRange" not in store_symbol: + pytest.skip("useCalculatorStore symbol not found - test fixture may need updating") + + # Request references + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Check for duplicate references (same file, line, and character) + seen_locations = set() + duplicates = [] + + for ref in refs: + # Create a unique key for this reference location + uri = ref.get("uri", "") + if "range" in ref: + line = ref["range"]["start"]["line"] + character = ref["range"]["start"]["character"] + location_key = (uri, line, character) + + if location_key in seen_locations: + duplicates.append(location_key) + else: + seen_locations.add(location_key) + + assert len(duplicates) == 0, ( + f"Found {len(duplicates)} duplicate reference locations. " + f"The dual-LSP architecture should deduplicate references from both servers. " + f"Duplicates: {duplicates}" + ) + + +@pytest.mark.vue +class TestVueEdgeCases: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: + full_tree = language_server.request_full_symbol_tree() + + # Helper to extract all file paths from symbol tree + def extract_paths_from_tree(symbols, paths=None): + """Recursively extract file paths from symbol tree.""" + if paths is None: + paths = [] + + if isinstance(symbols, list): + for symbol in symbols: + extract_paths_from_tree(symbol, paths) + elif isinstance(symbols, dict): + # Check if this symbol has a location + if "location" in symbols and "uri" in symbols["location"]: + uri = symbols["location"]["uri"] + # Extract the path after file:// + if uri.startswith("file://"): + file_path = uri[7:] # Remove "file://" + paths.append(file_path) + + # Recurse into children + if "children" in symbols: + extract_paths_from_tree(symbols["children"], paths) + + return paths + + all_paths = extract_paths_from_tree(full_tree) + + # Verify we have files from expected directories + # Note: Symbol tree may include duplicate paths (one per symbol in file) + components_files = list({p for p in all_paths if "components" in p and ".vue" in p}) + stores_files = list({p for p in all_paths if "stores" in p and ".ts" in p}) + composables_files = list({p for p in all_paths if "composables" in p and ".ts" in p}) + + assert len(components_files) == 3, ( + f"Symbol tree should include exactly 3 unique Vue components (CalculatorButton, CalculatorInput, CalculatorDisplay). " + f"Found {len(components_files)} unique component files: {[p.split('/')[-1] for p in sorted(components_files)]}" + ) + + assert len(stores_files) == 1, ( + f"Symbol tree should include exactly 1 unique store file (calculator.ts). " + f"Found {len(stores_files)} unique store files: {[p.split('/')[-1] for p in sorted(stores_files)]}" + ) + + assert len(composables_files) == 2, ( + f"Symbol tree should include exactly 2 unique composable files (useFormatter.ts, useTheme.ts). " + f"Found {len(composables_files)} unique composable files: {[p.split('/')[-1] for p in sorted(composables_files)]}" + ) + + # Verify specific expected files exist in the tree + expected_files = [ + "CalculatorButton.vue", + "CalculatorInput.vue", + "CalculatorDisplay.vue", + "App.vue", + "calculator.ts", + "useFormatter.ts", + "useTheme.ts", + ] + + for expected_file in expected_files: + matching_files = [p for p in all_paths if expected_file in p] + assert len(matching_files) > 0, f"Expected file '{expected_file}' should be in symbol tree. All paths: {all_paths}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_document_overview(self, language_server: SolidLanguageServer) -> None: + app_file = os.path.join("src", "App.vue") + overview = language_server.request_document_overview(app_file) + + # Overview should return a list of top-level symbols + assert isinstance(overview, list), f"Overview should be a list, got: {type(overview)}" + assert len(overview) >= 1, f"App.vue should have at least 1 top-level symbol in overview, got {len(overview)}" + + # Extract symbol names from overview + symbol_names = [s.get("name") for s in overview if isinstance(s, dict)] + + # Vue LSP returns SFC structure (template/script/style sections) for .vue files + # This is expected behavior - overview shows the file's high-level structure + assert ( + len(symbol_names) >= 1 + ), f"Should have at least 1 symbol name in overview (e.g., 'App' or SFC section), got {len(symbol_names)}: {symbol_names}" + + # Test overview for a TypeScript file + store_file = os.path.join("src", "stores", "calculator.ts") + store_overview = language_server.request_document_overview(store_file) + + assert isinstance(store_overview, list), f"Store overview should be a list, got: {type(store_overview)}" + assert len(store_overview) >= 1, f"calculator.ts should have at least 1 top-level symbol in overview, got {len(store_overview)}" + + store_symbol_names = [s.get("name") for s in store_overview if isinstance(s, dict)] + assert ( + "useCalculatorStore" in store_symbol_names + ), f"useCalculatorStore should be in store file overview. Found {len(store_symbol_names)} symbols: {store_symbol_names}" + + # Test overview for another Vue component + button_file = os.path.join("src", "components", "CalculatorButton.vue") + button_overview = language_server.request_document_overview(button_file) + + assert isinstance(button_overview, list), f"Button overview should be a list, got: {type(button_overview)}" + assert ( + len(button_overview) >= 1 + ), f"CalculatorButton.vue should have at least 1 top-level symbol in overview, got {len(button_overview)}" + + # For Vue files, overview provides SFC structure which is useful for navigation + # The detailed symbols are available via request_document_symbols + button_symbol_names = [s.get("name") for s in button_overview if isinstance(s, dict)] + assert len(button_symbol_names) >= 1, ( + f"CalculatorButton.vue should have at least 1 symbol in overview (e.g., 'CalculatorButton' or SFC section). " + f"Found {len(button_symbol_names)} symbols: {button_symbol_names}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_directory_overview(self, language_server: SolidLanguageServer) -> None: + components_dir = os.path.join("src", "components") + dir_overview = language_server.request_dir_overview(components_dir) + + # Directory overview should be a dict mapping file paths to symbol lists + assert isinstance(dir_overview, dict), f"Directory overview should be a dict, got: {type(dir_overview)}" + assert len(dir_overview) == 3, f"src/components directory should have exactly 3 files in overview, got {len(dir_overview)}" + + # Verify all component files are included + expected_components = ["CalculatorButton.vue", "CalculatorInput.vue", "CalculatorDisplay.vue"] + + for expected_component in expected_components: + # Find files that match this component name + matching_files = [path for path in dir_overview.keys() if expected_component in path] + assert len(matching_files) == 1, ( + f"Component '{expected_component}' should appear exactly once in directory overview. " + f"Found {len(matching_files)} matches. All files: {list(dir_overview.keys())}" + ) + + # Verify the matched file has symbols + file_path = matching_files[0] + symbols = dir_overview[file_path] + assert isinstance(symbols, list), f"Symbols for {file_path} should be a list, got {type(symbols)}" + assert len(symbols) >= 1, f"Component {expected_component} should have at least 1 symbol in overview, got {len(symbols)}" + + # Test overview for stores directory + stores_dir = os.path.join("src", "stores") + stores_overview = language_server.request_dir_overview(stores_dir) + + assert isinstance(stores_overview, dict), f"Stores overview should be a dict, got: {type(stores_overview)}" + assert ( + len(stores_overview) == 1 + ), f"src/stores directory should have exactly 1 file (calculator.ts) in overview, got {len(stores_overview)}" + + # Verify calculator.ts is included + calculator_files = [path for path in stores_overview.keys() if "calculator.ts" in path] + assert len(calculator_files) == 1, ( + f"calculator.ts should appear exactly once in stores directory overview. " + f"Found {len(calculator_files)} matches. All files: {list(stores_overview.keys())}" + ) + + # Verify the store file has symbols + store_path = calculator_files[0] + store_symbols = stores_overview[store_path] + store_symbol_names = [s.get("name") for s in store_symbols if isinstance(s, dict)] + assert ( + "useCalculatorStore" in store_symbol_names + ), f"calculator.ts should have useCalculatorStore in overview. Found {len(store_symbol_names)} symbols: {store_symbol_names}" + + # Test overview for composables directory + composables_dir = os.path.join("src", "composables") + composables_overview = language_server.request_dir_overview(composables_dir) + + assert isinstance(composables_overview, dict), f"Composables overview should be a dict, got: {type(composables_overview)}" + assert ( + len(composables_overview) == 2 + ), f"src/composables directory should have exactly 2 files in overview, got {len(composables_overview)}" + + # Verify composable files are included + expected_composables = ["useFormatter.ts", "useTheme.ts"] + for expected_composable in expected_composables: + matching_files = [path for path in composables_overview.keys() if expected_composable in path] + assert len(matching_files) == 1, ( + f"Composable '{expected_composable}' should appear exactly once in directory overview. " + f"Found {len(matching_files)} matches. All files: {list(composables_overview.keys())}" + ) diff --git a/test/solidlsp/vue/test_vue_error_cases.py b/test/solidlsp/vue/test_vue_error_cases.py new file mode 100644 index 00000000..5396666f --- /dev/null +++ b/test/solidlsp/vue/test_vue_error_cases.py @@ -0,0 +1,422 @@ +import os +import sys + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.vue + +IS_WINDOWS = sys.platform == "win32" + + +class TypeScriptServerBehavior: + """Platform-specific TypeScript language server behavior for invalid positions. + + On Windows: TS server returns empty results for invalid positions + On macOS/Linux: TS server raises exceptions with "Bad line number" or "Debug Failure" + """ + + @staticmethod + def raises_on_invalid_position() -> bool: + return not IS_WINDOWS + + @staticmethod + def returns_empty_on_invalid_position() -> bool: + return IS_WINDOWS + + +class TestVueInvalidPositions: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_negative_line_number(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + result = language_server.request_containing_symbol(file_path, -1, 0) + + assert result is None or result == {}, f"Negative line number should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_negative_character_number(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol with negative character number. + + Expected behavior: Should return None or empty dict, not crash. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at invalid negative character + result = language_server.request_containing_symbol(file_path, 10, -1) + + # Should handle gracefully - return None or empty dict + assert result is None or result == {}, f"Negative character number should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol beyond file length. + + Expected behavior: Raises IndexError when trying to access line beyond file bounds. + This happens in the wrapper code before even reaching the language server. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at line 99999 (way beyond file length) + # The wrapper code will raise an IndexError when checking if the line is empty + with pytest.raises(IndexError) as exc_info: + language_server.request_containing_symbol(file_path, 99999, 0) + + # Verify it's an index error for list access + assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_character_number_beyond_line_length(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol beyond line length. + + Expected behavior: Should return None or empty dict, not crash. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at character 99999 (way beyond line length) + result = language_server.request_containing_symbol(file_path, 10, 99999) + + # Should handle gracefully - return None or empty dict + assert result is None or result == {}, f"Character beyond line length should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_at_negative_line(self, language_server: SolidLanguageServer) -> None: + """Test requesting references with negative line number.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = language_server.request_references(file_path, -1, 0) + assert result == [], f"Expected empty list on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_references(file_path, -1, 0) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_at_invalid_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition at invalid position.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = language_server.request_definition(file_path, -1, 0) + assert result == [], f"Expected empty list on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_definition(file_path, -1, 0) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) + + +class TestVueNonExistentFiles: + """Tests for handling non-existent files.""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_document_symbols_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting document symbols from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return empty result. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + # Should raise an appropriate exception or return empty result + try: + result = language_server.request_document_symbols(nonexistent_file) + # If no exception, verify result is empty or indicates file not found + symbols = result.get_all_symbols_and_roots() + assert len(symbols[0]) == 0, f"Non-existent file should return empty symbols, got {len(symbols[0])} symbols" + except (FileNotFoundError, Exception) as e: + # Expected - file doesn't exist + assert True, f"Appropriately raised exception for non-existent file: {e}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return None. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + # Should raise an appropriate exception or return None + try: + result = language_server.request_containing_symbol(nonexistent_file, 10, 10) + # If no exception, verify result indicates file not found + assert result is None or result == {}, f"Non-existent file should return None or empty dict, got: {result}" + except (FileNotFoundError, Exception) as e: + # Expected - file doesn't exist + assert True, f"Appropriately raised exception for non-existent file: {e}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting references from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return empty list. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + # Should raise an appropriate exception or return empty list + try: + result = language_server.request_references(nonexistent_file, 10, 10) + # If no exception, verify result is empty + assert result is None or isinstance(result, list), f"Non-existent file should return None or list, got: {result}" + if isinstance(result, list): + assert len(result) == 0, f"Non-existent file should return empty list, got {len(result)} references" + except (FileNotFoundError, Exception) as e: + # Expected - file doesn't exist + assert True, f"Appropriately raised exception for non-existent file: {e}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return empty list. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + # Should raise an appropriate exception or return empty list + try: + result = language_server.request_definition(nonexistent_file, 10, 10) + # If no exception, verify result is empty + assert isinstance(result, list), f"request_definition should return a list, got: {type(result)}" + assert len(result) == 0, f"Non-existent file should return empty list, got {len(result)} definitions" + except (FileNotFoundError, Exception) as e: + # Expected - file doesn't exist + assert True, f"Appropriately raised exception for non-existent file: {e}" + + +class TestVueUndefinedSymbols: + """Tests for handling undefined or unreferenced symbols.""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_for_unreferenced_symbol(self, language_server: SolidLanguageServer) -> None: + """Test requesting references for a symbol that has no references. + + Expected behavior: Should return empty list (only the definition itself if include_self=True). + """ + # Find a symbol that likely has no external references + file_path = os.path.join("src", "components", "CalculatorButton.vue") + + # Get document symbols + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find pressCount - this is exposed but may not be referenced elsewhere + press_count_symbol = next((s for s in symbols[0] if s.get("name") == "pressCount"), None) + + if not press_count_symbol or "selectionRange" not in press_count_symbol: + pytest.skip("pressCount symbol not found - test fixture may need updating") + + # Request references without include_self + sel_start = press_count_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + # Should return a list (may be empty or contain only definition) + assert isinstance(refs, list), f"request_references should return a list, got {type(refs)}" + + # For an unreferenced symbol, should have 0-1 references (0 without include_self, 1 with) + # The exact count depends on the language server implementation + assert len(refs) <= 5, ( + f"pressCount should have few or no external references. " + f"Got {len(refs)} references. This is not necessarily an error, just documenting behavior." + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_at_whitespace_only_line(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol at a whitespace-only line. + + Expected behavior: Should return None, empty dict, or the parent symbol. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Try position at line 1 (typically a blank line or template start in Vue SFCs) + result = language_server.request_containing_symbol(file_path, 1, 0) + + # Should handle gracefully - return None, empty dict, or a valid parent symbol + assert ( + result is None or result == {} or isinstance(result, dict) + ), f"Whitespace line should return None, empty dict, or valid symbol. Got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_at_keyword_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition at language keyword position. + + Expected behavior: Should return empty list or handle gracefully. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Try to get definition at a keyword like "const", "import", etc. + # Line 2 typically has "import" statement - try position on "import" keyword + result = language_server.request_definition(file_path, 2, 0) + + # Should handle gracefully - return empty list or valid definitions + assert isinstance(result, list), f"request_definition should return a list, got {type(result)}" + + +class TestVueEdgeCasePositions: + """Tests for edge case positions (0,0 and file boundaries).""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_at_file_start(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol at position (0,0). + + Expected behavior: Should return None, empty dict, or a valid symbol. + This position typically corresponds to the start of the file (e.g.,